Compare commits

...

195 Commits

Author SHA1 Message Date
Maurice bb3c0151d5 fix(journey): sort the stay dates by a compare, make the hint pills real buttons and keep the walk's no-boats default a constant 2026-10-21 21:34:53 +02:00
Maurice 3101b36f3d fix(journey,tours): read a day's stays as a read, give the services the request context in the e2e runs and hold the tests to the foot-only walks and the translated notices 2026-10-21 21:16:36 +02:00
Maurice ef4eac34c4 docs(dawarich): word the next-day note of a stay's time range without a dash 2026-10-21 20:55:02 +02:00
Maurice a518b8dfc2 fix(journey): give preparing the recorded days its own section in the journey settings, apart from the trip tracks 2026-10-21 20:54:22 +02:00
Maurice 8523bbb724 fix(journey): say when a stay runs into the next day, leave the doubtful stays of a day unticked and pin a day report to a stay of the day, not the night 2026-10-21 20:46:33 +02:00
Maurice 464d4e006c feat(tours): mark the places along the tour that are a stop of it already 2026-10-21 20:33:12 +02:00
Maurice 1797e1c70d fix(journey): frame the first map view around the entries and the plan, and never hand a removed map the basemap it waited for 2026-10-21 20:33:12 +02:00
Maurice 5dc945145f fix(journey): keep the journey page and maps within their lint gates, format the journey locales 2026-10-21 20:00:07 +02:00
Maurice a1180b2b19 fix(journey): open the map on the plan when nothing else is located, say when the recordings could not be read and open photo hits on the shared map 2026-10-21 19:08:25 +02:00
Maurice d62fc284e1 fix(journey): share the stays of a day report only with the recorded days, and hold the share read tests to the two new flags 2026-10-21 19:08:25 +02:00
Maurice 8f53d98d45 feat(journey): show the plan, what was recorded, the entries and the photos as layers of the journey map
Every journey map (the desktop aside, the phone screens and the shared page)
now carries four switches: Plan, Recorded, Entries and Photos, each with its
count, a spinner while it loads, the warning tone when it could not be read
and the reason when it has nothing to show. A switch only hides, in place:
the map is not rebuilt and keeps its view, and the choice is kept in this
browser per journey (per link on a shared page, by a hash of its token).

The plan draws the places the linked trips put on their days as hollow dashed
pins and the trips' GPX routes dashed; a place an entry was written about is
not drawn twice, the entry's pin carries a check instead. The stays a day
report was taken over from show as dots in the colour of their day. Marks on
one spot join into a disc with their number and open as a list; a picked
place or stay opens a card with its trip, days and times and leads to its
entry. A key beside the switches explains every pin and line and says that
a plan is no proof of a visit. The phone timelines now show the photos too.

The GPX routes come with the map's layers through the linked trips, read
only while the journey's track switch is on; the frozen /tracks answer stays
Studio's. The share settings on desktop and phone get Trip plan and Recorded
routes under Map, off by default and only switchable while the map is shared.
2026-10-21 18:55:54 +02:00
Maurice 9b71948497 feat(journey): read what a journey map shows beside its entries, and share its plan and recorded days on purpose
GET /api/journeys/:id/map-layers answers the planned places of the linked
trips (one per place, with every day the plan puts it on and the entry
written about it), the trips' GPX routes while the track switch is on, and
the located stays entries were taken over from. It goes through
journey_trips, so a trip nobody wrote about yet shows its plan too. Every
source is read on its own: one that fails says unavailable and the others
still draw. A position that is not one is left out, never moved.

GET /api/public/journey/:token/map-layers cuts the same on the server by the
link: nothing while the map is not shared, the plan and its routes only with
share_plan, the recorded days only with share_recordings and only beside a
published entry, stays only of published entries. Migration 046190 adds both
flags to journey_share_tokens, off by default, so a journey that is public
already shows what it showed before. The share link REST route and the MCP
share tool take them alike; get_journey_map_layers reads the layers over MCP
on the same access, a route by its size only.
2026-10-21 18:15:24 +02:00
Maurice bb10b94046 fix(journey): mark only the days a run reaches as prepared, name trips out of reach, show editors their own status in the day review and say when a fetch is already running 2026-10-21 17:53:39 +02:00
Maurice 94c369b913 feat(journey): prepare the recorded days of a journey's trips in the background, switched on per journey, with each day's state
Journey Settings get "Prepare recorded days automatically" under GPX Tracks,
off by default and set by the owner. With it on, the Dawarich background
check also reads the linked trips of the journey for the owner and every
editor with a connection of their own, within the same 400 day reach and
windows as the trip sync, and only for new and changed stays: a trip the
person travels on is not asked twice, and the stays of a trip they are not
on are filed under no trip. Nothing is written into the journey; the stays
wait at their day, and a stay taken over or left out is never offered again.

Under the switch the caller sees when their stays were last fetched, or why
not (no connection, no dated trip, background check off, the reason of the
last failure beside the state still shown), with a "Fetch now" that the
switch also runs when it is turned on. A day of the trips that is not final
yet carries a dot on its take-over button, on the phone in the stay sheet,
and the tooltip says whether it is still running, was fetched before it
ended, was not fetched yet or shows an older state after a failed refresh.
The day review says so for today too.

Server: migration 046180 adds journeys.auto_prepare_recordings and
journey_recording_prep (the last attempt, last success and last error per
journey and person). JourneyRecordingPrepService in the journey domain lists
the prepared trips and keeps the status; DawarichSyncService walks those
trips in the same loop as the person's own and records each journey's
outcome. GET /api/journeys/:id/recordings/prep answers the caller's status
under a @trek/shared contract, the MCP tool get_journey_recording_prep reads
the same, and update_journey takes auto_prepare_recordings like the PUT
contract. The column stays out of the plugin output. All 27 locales carry
the strings; the Journey, Dawarich and MCP tool wiki pages describe it.
2026-10-21 17:39:33 +02:00
Maurice 3f1d1a57cc fix(journey): take a day over from the phone stay sheet, keep a picked position inside its group and send stays without a place unpinned
A journey on a phone with stays on days that hold no entry offers each waiting day in the Dawarich stay sheet, so a day can be built from its stays alone; desktop and phone read the same days through openStayDatesWithin. A position picked for a group falls back to the longest stay with a place once that stay is unchosen or split off, a merged entry of stays without a position goes out unpinned instead of being refused, and a stay taken over meanwhile is never joined across. review_journey_day_stays is no longer marked read-only, since it refreshes the caller's stored stays. The already-taken notice no longer claims the stays were in this journal. An e2e case checks the drafts stay off the public share.
2026-10-21 16:49:47 +02:00
Maurice 7c1de6ed82 feat(journey): take a recorded day over with its stays, chosen, renamed and joined into drafts or one day report
A journey day now has a list button in its header beside the route, with the
number of stays still waiting, and the row of the day's Dawarich stays gets a
"Take over day" button; on a phone the journey's More options offers it for
the day of the card shown. The dialog reads the day again from the caller's
own Dawarich and shows the route and the stays on one map, numbered in the
order they were lived and pinned in the colour of the day, beside a list with
times, durations, the stays Dawarich only guessed or is unsure about, the ones
without a position, the entries the journal has on the day and how many people
will see the drafts. When Dawarich does not answer, it says why and shows the
stays of the last sync.

Open stays start chosen, the same place twice in a row starts joined as one
entry under a title and a chosen map position, any two neighbours can be
joined or kept apart, a name can be corrected and a stay left out. "Day
report" turns every chosen stay into one entry that lists them with their
times, at a chosen stay or off the map. The day's route can go along when the
journey keeps none yet. Nothing is written before "Take over N drafts", and a
blank name or title holds it back with the reason. Stays left out are dismissed
and can be restored in the stay list.

The take-over writes drafts only, never touches what the journal holds, and is
one transaction: every stay is checked to be the caller's and of that day, the
entries, the stays they hold and the marked suggestions commit together, and
the entries are announced after the commit. A stay the journey holds already
is reported instead of written twice, so a retry after a lost answer is
harmless; a failed attempt reads the day again and keeps the choices, and a
route that fails after the drafts can be kept again alone.

Server: migration 046170 adds journey_entry_stays (one row per stay of an
entry with its own name, place and times, unique per journey, source, person
and visit, gone with its entry), owned by the journey domain through
JourneyEntryStaysService. The single stay accept records its stay there too,
over REST and MCP alike. DawarichSyncService.refreshDay reads one day's visits
and upserts them without flagging anything missing; DawarichDayStaysService
lists and settles a day's stays. JourneyDayImportService in journey-recordings
serves POST /api/journeys/:id/recordings/:date/stays/review and /import with
@trek/shared contracts, and the MCP tools review_journey_day_stays and
take_over_journey_day_stays run on the same service and checks. createEntry
can write without announcing for a caller that announces after its commit.

The desktop dialog and the phone sheet of the route (JRN-01) and of the
take-over share one frame each. All 27 locales carry the strings; the Journey
and Dawarich wiki pages and the MCP tool list describe it.
2026-10-21 16:31:40 +02:00
Maurice fbf6cd2840 fix(journey): read a day kept meanwhile again to offer replace, date the phone row in the app language 2026-10-21 15:18:01 +02:00
Maurice 5116f7be2c fix(journey): refuse the second of two saves of a recorded day at once unless it asked to replace 2026-10-21 15:18:01 +02:00
Maurice 4f60cc5c98 fix(integrations): cut a journey day in the zone of its own first track, never in the UTC Dawarich writes times in 2026-10-21 15:18:00 +02:00
Maurice 65bb2d95eb feat(journey): keep the route recorded on a journey day from Dawarich, previewed first, with its gaps
A day header in the journey timeline now carries the Dawarich mark for an
editor whose Dawarich answers (dimmed with the reason while offline). It opens
a dialog that names the journey and the day and reads that one local day from
the caller's own connection: a sketch of the route, distance, first and last
time, pieces, gaps without recording, modes and whether tracks or thinned
points answered. The day is cut in the zone Dawarich records in where its
answer shows one, else in this device's, and a picker cuts it in any other.
Nothing is kept before "Keep route". On a phone the same sheet opens from the
journey's More options for the day of the card shown.

What is kept is the preview that was seen: the save reads the day again and
refuses with recording_changed when its hash moved, and the dialog shows the
new version. The same day taken over twice keeps one version and announces
nothing; a different version is replaced only on "Replace", never touching an
entry or text. A failed read throws before any write, so the kept version
stays. Gaps stay gaps: points are split where silence ends elsewhere, and no
day is joined to the one before. The dialog says how many other people will
see the route; editors may keep their own recording.

Kept days show in the day header (distance, whose recording, since when,
still running) and on the desktop and phone journey maps in the colour of
their day's pins, winning over the live trail of their date, and stay drawn
without Dawarich. A trip's GPX track is now drawn dashed as the plan, recorded
routes solid. Shared links do not show recorded days. Remove route asks first
and works without a connection.

Server: migration 046160 adds journey_recorded_days (one row per journey,
local day and source, segments as JSON, zone, window, fetch time, hash,
importer kept past their account). The journey domain owns the rows through
JourneyRecordedDaysService; the new journey-recordings domain takes a day
over through DawarichTracksService.fetchDay, which reads past the cache, asks
a window wide enough for every zone and refuses with DomainErrors. REST under
/api/journeys/:id/recordings (list, preview, PUT and DELETE /:date) with
@trek/shared contracts, and MCP list_journey_recorded_days,
preview_journey_recorded_day, save_journey_recorded_day and
delete_journey_recorded_day on the same service and access. A new user event
journey:recording:updated keeps every open map current. JourneyTrack gains an
optional kind. All 27 locales carry the strings; the Journey and Dawarich wiki
pages and the MCP tool list say what it does.
2026-10-21 15:05:21 +02:00
Maurice cbca3b2851 test(maps): the overpass place names cases get a suite of their own, the service suite back under its size 2026-10-21 13:53:24 +02:00
Maurice 8a75712041 fix(tours): tell a router that did not answer from a walk no way joins, count each boat of a leg on its own into the day and its arrivals 2026-10-21 13:53:21 +02:00
Maurice 6e13f7e655 fix(tours): keep a city walk on foot across the water, mark a crossing by boat apart from the walking time and save it only once taken 2026-10-21 13:16:43 +02:00
Maurice 41f3267503 fix(tours): name every derived field of a walk's remaining time, read a second run on the same day, say when only the way back is left 2026-10-21 12:35:02 +02:00
Maurice 2a4a5617dc feat(tours): show the walking time left beside a city walk's progress, with what it counts from and the stays apart 2026-10-21 12:26:36 +02:00
Maurice 43353b9394 feat(tours): read the walking time left of a city walk on a day over MCP, apart from its stays and the whole walk 2026-10-21 12:16:57 +02:00
Maurice 4eaf5e6a11 fix(tours): read e-bike chargers as mapped, keep unnamed ones on the map search 2026-10-21 12:05:38 +02:00
Maurice 1a722e63fb feat(tours): find e-bike charging points along a bike tour, only those tagged for bicycles, with what they state 2026-10-21 11:58:39 +02:00
Maurice 924e38472e fix(maps): stretches beside the overpass probe wait for it, empty note only after a full search 2026-10-21 11:43:04 +02:00
Maurice bf2efca8f0 fix(tours): let a failed search along the tour really ask again, with a probing breaker, hedged mirrors and a countdown 2026-10-21 11:35:03 +02:00
Maurice 2377a63a05 fix(tours): offer the place's undo only while its insert is the last history step, and explain the add button with a tooltip 2026-10-21 11:14:39 +02:00
Maurice 377619abd1 fix(tours): keep the place card open while its position is picked, with slots, a map preview and undo 2026-10-21 11:08:46 +02:00
Maurice 988890866a fix(ski-routing): sort the network cache key by code unit, not by default 2026-10-20 23:18:54 +02:00
Maurice 0dda5bc3e9 fix(tours): name the piste router switches and the stage message keys where the key scan sees them 2026-10-20 23:06:29 +02:00
Maurice 7fa8b3fe35 fix(tours): keep the day menu items with the stage logic their views import
The pair rule wants TourDayAddButton to import stageSpread; the items it
builds belong there anyway. The addon manager test answers the Tours
switches, so a failed list is the only thing that toasts.
2026-10-20 22:54:57 +02:00
Maurice 112017a9e8 fix(tours): clear the stage spread's lint, read the vias after render and test against the reloaded vias
The vias the spread keeps on their legs are taken over in a layout effect
instead of during render, the route redraw takes the planner's async
updateRouteForDay, and the menu items for whole or stages move out of the
button component into their own module. Two leftovers of the old day pills
in tourParts go. The hook test's reanchor reloads the vias like the real
one does, and the detail test finds the button by its label, Add to Day.
2026-10-20 22:44:42 +02:00
Maurice cb5c5ab4a1 chore(client): lower the planner page size baseline to its count 2026-10-20 22:38:29 +02:00
Maurice bed9f53615 fix(deps): raise the MCP SDK to 1.32 and hono to 4.13.11
GHSA-22jm-h49p-29qw and GHSA-6prh-2h8m-c8cw (MCP SDK, high) and
GHSA-5r4p-p66f-jhc7 (hono, moderate) are fixed in these versions. npm audit
for what the server installs reports none left.
2026-10-20 22:29:33 +02:00
Maurice a8e2486cd3 test(tours): read the last undo without Array.at, which the client lib does not offer 2026-10-20 22:23:45 +02:00
Maurice 5d5015066f chore(planner): keep the planner page at its size after the stage choice 2026-10-20 22:17:53 +02:00
Maurice 3e8d192305 fix(tours): keep the road trip's vias on their legs when stages spread, and ask on the phone too
Spreading a multi-day tour wrote through the store directly and skipped
the via re-anchoring every other write on the plan does. A tour with a
start counts as a road-trip stop, so taking the whole tour, a stale stage
or a moved stage off the middle of a day shifted every later stop, and the
vias anchored by position shaped the wrong leg. The undo had the same gap
when it put the whole tour back mid-day. Each stop a spread or its undo
takes off a day now carries the day's vias along (reanchorAfterRemove),
and the whole tour put back carries them the other way
(reanchorAfterInsert). The stops are read from the store as each write
goes out and the vias are kept as the last correction left them, since
one run can touch a day twice before the plan renders.

The phone's place sheet greyed out a day that holds a multi-day tour, so
its stages could not be spread from there. It now checks the day the way
the desktop menus do and leaves the choice to the plan, which opens the
card with only the stages offered.

The second step of the day menu names the day picked, and a greyed-out
"Whole tour on this day" says why right under it. The toast for a tour
that sat whole on several days now says "days" instead of "day", in all
27 languages.
2026-10-20 22:17:38 +02:00
Maurice bafb74f872 feat(tours): put a multi-day tour on a day whole or a stage a day from it on
Putting a multi-day tour on a day through any of the usual ways put the
whole tour on that one day. Now the way it goes on is a choice: the whole
tour on this day, or stage 1 on this day and each next stage on the next
day of the trip.

The day menu of the tour lists and the detail's "Add to day" ask in the
same menu, as a second step after the day; a tour dragged onto a day, or
added from a place where no menu asked, gets a small dialog on the desktop
and a card on the phone. Only the trip's days are used: when fewer days
are left than stages, the menu says up to which stage fits and the rest
stay without a day. A stage already on a day moves to its new day, the
whole tour comes off the days it was on, and a stage the route no longer
has comes off too. The writes run in order through the store, the repo
and the mutation queue, so they work offline, and one undo takes the
spread back, the whole tour onto its day again.

The plan lives in stageSpread.ts, the writes and the question in
useTourDayChoice, which every way onto a day now passes through on both
the desktop and the phone.
2026-10-20 22:17:20 +02:00
Maurice b71e32cf12 fix(tours): clear the last lint, knip and wiring findings of the follow-up
The unused day pills go, a client-only type stops being exported, the place
form context moves out of the page hook so its view model stays the first
object it returns, the place form test helpers split the stub component
from the plain setup, and four router files are formatted.
2026-10-20 22:13:25 +02:00
Maurice 786df612fd refactor(planner): split the tour day time growth out of four size-capped files
The place form's time section moves into PlaceFormTimeSection, its tour test
into PlaceFormModal.tour.test.tsx over a shared tests/helpers/placeForm setup,
the page hook hands the place form its close, day plan and tour, and the day
plan's surface classes and tones move into dayPlanSurfaces. The size baseline
drops to the new counts.
2026-10-20 22:02:48 +02:00
Maurice e6002444a1 test(server): narrow a partial ways answer the way the server build can, format three tests 2026-10-20 21:56:06 +02:00
Maurice e453816ded test(shared): count the five tours.ski.summary keys in the tours domain 2026-10-20 21:43:48 +02:00
Maurice b9ca822d51 chore(shared): format the ski schemas and the new CJK tours strings 2026-10-20 21:43:47 +02:00
Maurice 20537193b6 test(tours): expect ski_options null on non-ski rows, let the ski choice settle before asserting 2026-10-20 21:43:47 +02:00
Maurice 60611aee65 test(server): run sequential awaits through oneByOne instead of a loop 2026-10-20 21:43:47 +02:00
Maurice 6c66cff365 fix(ski-routing): no promise in a condition and no await in a loop, ski route type from its schema
ski-areas.service checks the pending load and download against null and
undefined, the area refresh lane recurses instead of awaiting in a loop,
TourSkiRoute is inferred from its schema, the ways answer is read by
complete === false, and /v1/routes no longer needs a truncated field.
2026-10-20 21:43:46 +02:00
Maurice d3f574f225 fix(tours): match a saved ski route by its ski choice, and keep the planner under the lint limits
The saved ski route compared against an undefined skiKey, so opening a saved
ski tour threw. The held seed now lives in useSavedSkiSeed and is matched by
skiRouteKey(skiChoice). The ski route facts move to skiRouteFacts.ts so the
summary and leg rows only export components, the rail renders
SkiPlannerSummary, and openTour, useTourRoute and useAddonManager shed the
branches and lines the new ski code had added.
2026-10-20 21:43:46 +02:00
Maurice e5c5ffbc00 fix(tours): one get_tour_plan description for routed ski legs, entities as generated
A ski leg is routed where the piste router planned it and free elsewhere;
the description said both. The collection tours entity takes its generated
form again after the merge.
2026-10-20 21:17:08 +02:00
Maurice 562176bd83 chore(db): number the ski options migration after the Tours default 2026-10-20 21:16:09 +02:00
Maurice ae699187f7 fix(tours): open a saved ski tour as saved and keep what its router planned
A saved ski tour now opens with the line, heights, time, build and
planned legs it was saved with, read from the place this device keeps,
and nothing is asked of the piste router until a point, an option or the
discipline changes. Opened offline, while TREK Places is down or with
piste routing switched off, it no longer turns every leg straight, and a
rename saves the planned tour as it was. A kept line whose length is not
the tour's is another save's, and the tour is planned afresh.

- the save names the legs the router planned (ski_planned_legs, checked
  against the tour's own legs); get_tour_plan reports each leg routed or
  drawn from them instead of from the build alone, a tour that names none
  counting as planned only with the router's time
- copies keep ski_options in every path: in the trip, taken over, kept
  in a collection and made from one (collection_tours gets the column in
  the same unshipped migration)
- the route summary shows the data build, the km of runs, the height
  skied down on them and the lift rides, and the credit the server sends
  in place of a fixed one; a tour opened as saved says so
- answers refused while piste routing is off, or planned on an older kept
  copy, are not remembered, so they are asked again later
- terrain heights between known ones are filled in one walk along the line
- i18n: five summary keys in all 27 locales
2026-10-20 21:15:45 +02:00
Maurice 1d72fbd25f fix(ski-routing): keep one job's timeout to itself and bound the area refresh
The router thread now takes one job at a time from a queue kept on the
main thread, so a job's clock starts when the thread takes it up. A job
that times out ends only its own thread, and only while that thread is
still the current one; the jobs in line go to a fresh thread instead of
waiting out their own timers and killing the new thread in turn. A
thread that dies fails the job it held, and a thread that cannot start
fails each job on its own.

- the daily refresh fetches changed areas two at a time and stops once
  the TREK Places breaker opens; the rest keep their older copies
- the winter index cap is on the unpacked JSON fetch hands over, so it
  follows from the host's 8 MB gzip cap (64 MB) instead of matching it
- a build name is read to the rule a ski tour stores it by, so a build
  TREK could not save is refused when the winter layer is read
- an unused import in the piste router contract is gone
2026-10-20 21:15:21 +02:00
Maurice 2d0c0597b3 feat(tours): plan ski tours on the piste router in the planner
A ski tour with a discipline now sends its legs to the piste router and
takes back runs, lifts and trails: each stretch is coloured by its grade
in the area's scheme, links and lift rides down are dotted, and a leg the
router cannot plan stays straight and unrouted with its reason and the one
change that would plan it. Snowshoe and winter hike legs off the piste
data follow summer paths instead, said so. The router's time and heights
are used once every leg is planned, and the options, the build and the
time are saved with the tour.

- skiRouteRepo and api: one request per tour, batched, remembered unless
  the answer may change, offline legs failed as offline
- planner: router options per discipline, leg steps and failure rows with
  fixes, a piste route summary, draft and reopen keep the options
- admin: switch for the piste router under Tours
- i18n: new ski keys in all 27 locales, ski info and time texts rewritten
2026-10-20 21:15:07 +02:00
Maurice a85a50e186 feat(tours): keep a ski tour's piste router options, build and time
A ski tour now saves the options its legs were planned with and the piste
data build they ran on, as one JSON column (a new migration, since the
ski migrations have shipped on this branch); the server keeps only the
options its discipline reads. Both stay off every other type. The refusal
of a ski time is lifted: the piste router works one out, and the planner
sends it only when every leg followed the piste data. get_tour_plan names
the legs of a tour the router planned as routed, and list_tours says what
the new fields hold.
2026-10-20 21:14:44 +02:00
Maurice c8eb510bd4 feat(ski-routing): plan ski tours on the piste graphs of TREK Places
A new Nest domain plans the legs of a ski and winter tour on the runs,
lifts, cross-country trails, ski touring and winter paths the Places host
builds from OpenStreetMap, one SGR1 graph per ski area. TREK fetches the
index once, downloads an area by its id when a planner first plans in it,
checks it against the index's SHA-256 and keeps it in data/ski; no
clicked point ever leaves the server. A leg whose area is slower than two
seconds answers loading, and an older copy stands in while the host is
down.

The reader decodes the writer's golden file byte for byte, including the
two edge flags it added (area_has_run, type_unknown). The search is A* in
a worker thread over a profile per discipline (alpine, snowboard,
cross-country by technique, touring, snowshoe, winter hike) with the
times and penalties of the cost model, up to five snap candidates per
point, lift stations as entry points, legs that go on from each other,
pieces of a split network stitched by their boundary nodes, other ways by
the penalty method, and a failed leg explained by relaxing one rule at a
time (difficulty, lift class, technique, riding down, uphill) without ever
handing out the relaxed line.

POST /api/trips/:tripId/ski/route, GET .../ski/area-near and
GET .../ski/network/:areaId sit behind Tours and the trip, with
plan_ski_route as their MCP twin. A daily job looks for a new build and
sweeps areas unused for 90 days. The admin switch ski_routing_enabled is
on wherever TREK Places is.
2026-10-20 21:13:48 +02:00
Maurice beea4c762b refactor(server): move the routing heap and the metre helpers out of the paddle domain
The binary heap and the flat-frame distance helpers are not about water;
the piste router needs the same two. They move to src/lib/geo, where any
domain may import them without reaching into paddle-routing. Nothing
changes in what they do.
2026-10-20 21:12:15 +02:00
Maurice d924dcf99d fix(tours): keep the Places layers off the search breaker and keep partial way answers
The ways and routes layers now count failures on a breaker of their own, so
busy or slow layer answers no longer send place search to the fallback for a
minute, and a layer's 404 no longer clears the count of search failures.

When a later batch of /v1/ways fails, the batches that answered are kept and
only the ids still open go to the Overpass mirrors.

The wiki, the compose file and the chart now say that the way ids of a planned
tour and the horse routes box go to the TREK Places API.
2026-10-20 21:12:09 +02:00
Maurice 371083ed47 feat(tours): read way tags and horse routes from the TREK Places layers
The trail grades under a tour (BIK-05, MAP-03, MAP-04, RUN-02) and the
horse routes around a map view (HOR-01) asked the public Overpass mirrors.
The places host now serves both from its own planet build: GET /v1/ways
for the tags of ways by id and GET /v1/routes for route relations per box,
each in Overpass element form.

trek-places.client gains trekPlacesWays (batches of 500, one after
another) and trekPlacesRoutes, both through getJson with its off switch,
breaker, timeout, size cap and instance header, each element validated on
its own. TrekPlacesLayersService in the maps domain hands their answers to
the tours services, or why there is none (switched off, layer not built,
failed), and logs that once per layer until it answers again. Both tours
services ask the layer first and fall back to the existing Overpass path;
the responses TREK sends its clients are unchanged.
2026-10-20 21:12:09 +02:00
Maurice eaeaf68352 chore(server): format the tours MCP test 2026-10-20 21:11:44 +02:00
Maurice 5156fa2c0a test(addons): check the Tours migration against the addon table as it was before
The table holds the seeded addons too, so compare against a snapshot taken before the migration with only tours switched on.
2026-10-20 21:11:44 +02:00
Maurice 6c6e285c3d test(tours): expect the time estimate on imported tours and hikes without a time
Since the estimate rule, an import without its own time shows the worked-out estimate on the day plan tile, and the sidebar metrics lead with it. Add the estimate field to the RS-01 fixture.
2026-10-20 21:11:43 +02:00
Maurice 93e1156aa4 refactor(tours): bring the tour time parts, collection rows and flyover back under the lint baseline
Move workedOutEndHint and totalText into tourDayTimeHint.ts so TourDayTimeParts only exports components, render the collection row's type icon without a component made during render, and pass the chase camera's lens and scene as one view object.
2026-10-20 21:11:43 +02:00
Maurice dc19807126 fix(tours): zoom the flyover around the rider and keep no stale GPX file
The flyover asked wheel and pinch to zoom around the centre with enable()
on handlers that were already on. MapLibre returns from enable() at once
in that case, so the option never took and a wheel zoom still went to the
cursor before the camera pulled back to the line. Each handler is now
switched off and on again with the centre anchor, and the same way back to
the cursor on pause, stop and unmount. The map stub refuses an enable() on
a handler that is on, like MapLibre does, so the tests see the real anchor.

The GPX file kept for the share sheet was only dropped when the tour's id
or name changed. A route saved elsewhere arrives live with a new revision
and the dialog kept handing out the old file. The kept file now carries the
revision it was read at; a new revision drops it and fetches again, and a
file a tap kept is not shared for another revision.
2026-10-20 20:56:19 +02:00
Maurice 5580ff71a9 feat(addons): Tours is on for every install
A fresh install gets Tours enabled from the addon seeder. An install that
already has the row is switched on by a migration that runs once, so an
admin who turns Tours off afterwards keeps it off.
2026-10-20 20:56:19 +02:00
Maurice e42622d71d feat(tours): let the user zoom during the 3D flyover
The chase camera set a fixed zoom and pitch on every frame, so wheel,
pinch and the +/- keys were undone at once. It now keeps the zoom and
tilt of the user's latest gesture and only follows position and
bearing. While a zoom is still easing the flyover holds for those
frames, since any camera move would cancel the gesture. Wheel and pinch
zoom around the rider while flying, and drag panning is off until the
flyover stops. Starting over returns to the flyover's own zoom.
2026-10-20 20:56:19 +02:00
Maurice 94674e7572 fix(tours): open the GPX share sheet inside the tap
Sharing a tour fetched the GPX file first and only then called
navigator.share, so the tap that asked had often run out by then. The
retry path kept that file for one more tap only, and a tap while a sheet
was still open made the browser throw InvalidStateError, which read as a
failed share.

The share button now fetches the file ahead where sharing is offered
(not while the planner has unsaved changes), and every tap hands a fresh
File built from the kept bytes to the sheet before anything is awaited.
A file a tap had to fetch is kept for the next tap. A tap while a sheet
is open is let be, and InvalidStateError and a closed sheet stay quiet;
only a real failure still says the download is left.
2026-10-20 20:56:19 +02:00
Maurice 7f14dde362 fix(tours): tidy the day times of a tour after review
The get_tour_plan description named the estimate of a tour "such a field"
right after the values of a recording, so it could be read as one of them.
It now follows the sentence on derived fields and says outright that it is
one of them, worked out from distance and heights or the pace of a run.

The arrow between a tour's start and end points from the start to the end
in a right-to-left language as well. A tour just added to a day, which
carries no tour_place_id until the next refresh, now says its overrun like
the row above it. A phone row of an import with a total the travellers
entered shows that one time, the estimate joining its words as on the
desktop. The German overrun line reads naturally without an item name.
2026-10-20 20:52:31 +02:00
Maurice 9765cf3fee feat(tours): give a tour its start and end in the day plan
A tour on a day had no place in the day's timeline: its time was not tied
to the slot it was planned in, so several hikes on one day could not be
lined up. A tour now sits there the way a place does, on the times of its
day entry, one model for every tour type. With a start and a known time
(the planned total, else the estimate of an import) its end follows as the
start plus that time; it is shown fainter, marked with an approximate sign
where it comes from an estimate, and says in its tooltip how it was worked
out. An end typed by hand wins. A day that holds one stage of a multi-day
tour gets no end from the whole route.

When the end runs past the start of the day's next timed item, the row
says so in one quiet line, as a late connection between two city walks
does; nothing is moved. The existing time fields of the day entry, on the
desktop form and the phone sheet, say what the end works out to while none
is typed. City walks keep their arrivals per station. The logic lives in
one module both day plans run on.
2026-10-20 20:52:30 +02:00
Maurice c38eb7ebf0 feat(tours): estimate the walking time of imported tours
A hike imported from GPX, KML/KMZ or wanderer had no time at all, so
several hikes on one day could not be planned against each other. It now
gets the walking time the planner times a hike by (DIN 33466), worked out
from its own distance, ascent and descent; a run gets its distance at the
pace entered. Without heights in the track the time is unknown rather
than the time on the flat, and types timed by a router, by a speed of
their own or by nothing get no estimate.

The estimate is derived on every read and never stored, so tours imported
before get it too and no stored or entered time is touched. The rule moved
to @trek/shared, where the planner, the client and the server read it.
The tour list, the day rows on desktop and phone and the tour detail say
it as an estimate, apart from a planned total; list_tours and
get_tour_plan carry it as time_estimate and name it as derived.
2026-10-20 20:52:30 +02:00
Maurice e653733390 fix(notifications): a little more room for the unread count, with a test 2026-10-20 20:52:23 +02:00
Maurice 2655e510fc fix(notifications): give the unread count room in its badge 2026-10-20 20:48:33 +02:00
Maurice 904eb31e62 fix(tours): even the tour detail's rows and footer
The times fill their row when there are two, the trip days are boxes in
the look of the measures with the date under the day, the footer's
collection button carries its label like the others and the copy button
says Copy, and the name field keeps its distance to a save button as large
as the close button.
2026-10-20 20:48:33 +02:00
Maurice 76ec302526 feat(tours): kept tours are rows of a collection's list, on the phone too
A tour kept in a collection was a card in a box of its own above the list,
and its button lost its look to the collections page's button reset. It is
now a row of the list like a saved place: its grade or type tile, its name,
type and the trip it came from, its measures, and Add to a trip, with
removing dimmed beside it. The list's search narrows the tours too. The
phone shows the same rows and takes a tour into a trip from a sheet, on the
same hooks as the desktop.
2026-10-20 20:48:33 +02:00
Maurice 810feba413 fix(tours): open the tour dialogs in the middle of the screen
The walk from a Google Maps link, the take-over from another trip and the
other new tour dialogs sat at the top of the screen; they now open centred
like the planner's own dialogs.
2026-10-20 20:15:05 +02:00
Maurice 4009588078 fix(tours): give the latest stage date a starting value
Sonar flagged the reduce without an initial value; every stage has a date at
that point, so the empty start never wins.
2026-10-20 19:27:58 +02:00
Maurice ef583ede18 fix(notifications): name each wanderer hint text in a key of its own
The three texts of a wanderer hint were picked in a nested conditional, which
the client's key check cannot read, so it counted them as unused.
2026-10-20 19:16:20 +02:00
Maurice 9bc543a387 chore(tours): keep the client ratchets where they were
Snowshoe is no longer exported, useRouteCalculation takes the stage stops
type through dayRoutePlan, and tourPlannerFacts and useTourRoute hand the
saved duration, the ski check and the planned key to small helpers.
2026-10-20 19:07:13 +02:00
Maurice 7291555e95 test(tours): follow the ski tour changes in the tests
The collection tour fixture no longer writes the dropped winter_activity
column, the drafts are saved as version 11, and a hike no longer offers
the pistes layer, which belongs to a ski track only.
2026-10-20 19:07:12 +02:00
Maurice 57f0e2f263 fix(tours): give the tour entities back their generated form
The format run rewrote two files that check:entities holds byte for byte
to the generator's output; .prettierignore excludes them for that reason.
2026-10-20 19:07:12 +02:00
Maurice de043cfb58 fix(tours): keep the get_tour_plan description one string
An apostrophe in the ski sentence ended the quoted description early, so the
tours MCP file no longer parsed and the server could not boot.
2026-10-20 18:53:40 +02:00
Maurice 69df85bfa2 chore(tours): format the ski and winter files 2026-10-20 18:41:55 +02:00
Maurice be6702f3ab fix(tours): keep the shipped ski migrations and follow the ski tour through
The winter label and four-discipline migrations are already on
feat/tours-board-rest, so a database that ran them would refuse to boot
or fail the old CHECK. 045500, 045520 and 046100 are back as they
shipped, and a new migration drops both winter_activity columns and
adds ski_discipline again with the six disciplines. The entity and
table types follow the column to its new place.

German, Hungarian, Indonesian and English no longer name the whole type
with the ski touring word, and the info dot speaks of ski tours and
tracks. search_tour_pois and its wiki row list rides and ski and winter
tours. Snowshoeing gets a snowshoe glyph instead of the walk's
footprints, a ski import without a discipline can be planned over (the
planner asks for it), and an overlay shown by default tries its tiles
afresh each time it comes on.
2026-10-20 18:40:48 +02:00
Maurice 164a3633ac feat(tours): plan ski and winter tours, snowshoeing and winter hiking among them
Ski becomes "Ski & winter", a type the planner plans with six disciplines
(alpine, snowboard, cross-country, ski touring, snowshoeing, winter hiking),
nothing preselected; saving needs a discipline, and a technique for
cross-country. Every leg is drawn straight between the points through
planner/skiRouting.ts, the one place the piste router will plug in, with its
heights from the elevation source and no ski time worked out.

The hike's winter label goes entirely: its migration, column, field, marks,
GPX rule, MCP field and strings. A hike is summer only; a ski tour with the
snowshoe discipline exports as snowshoeing. The unreleased ski migration takes
the six disciplines and the collection migration loses the winter column.

The pistes are offered for a ski tour, shown until switched off, and with no
tour open, never for a hike. Export, copy, collections, stages (untimed),
actual tracks, the atlas, list and day marks (one glyph per discipline), 3D,
planning over a ski import and list_tours/get_tour_plan know the planned ski
tour.
2026-10-20 18:40:48 +02:00
Maurice 204ee2ef86 chore(ci): give the server test typecheck room, format new client files
The server's src and tests together are past 570,000 lines and the test
typecheck needs about 4.3 GB, over Node's default heap; it now gets 8 GB like
the test jobs. Forty new client files are formatted, and the react-hooks/refs
baseline drops by the one warning a split hook removed.
2026-10-20 18:39:58 +02:00
Maurice baba2de344 refactor(planner): hand the day plan its tours as one bundle from the page hook
Which places are tours, their planned times and where their stages run
travel together to the desktop and the phone day plan. useTripPlannerPage
now puts them together as dayPlanTours, and the page passes that bundle
instead of naming the three props at both places.
2026-10-20 18:26:56 +02:00
Maurice 49dd2db040 refactor(planner): project a day's tours by assignment in one place
The day route plan and the day plan sidebar both built the map of a day's
stops projected onto their tours, each stage on its own stretch, by hand.
projectedById in dayTourProjection builds it for both, and the sidebar's
drive runs reuse the route plan's point type instead of spelling it out
twice.
2026-10-20 18:26:00 +02:00
Maurice 49e306ea48 refactor(map): report the GL viewport from a module of its own
reportGlViewport says what a GL map shows, its bounds and its zoom on the
raster tile scale, and has its own test for that scale. MapViewGL only
wires it to moveend and idle, and its test checks that wiring. The GL map
tests put the zoom back to its default before each test, as they do for
every other value a test overrides, instead of each test restoring it.
2026-10-20 18:23:50 +02:00
Maurice 1d7c8a63e2 refactor(tours): hold the planner's routing choices in a hook of their own
The hike's limit and preferences, the bike profile and the run profile were
four states with four refs kept in step by hand in every handler that set
them. useRoutingChoices holds them as one value with one ref, so a history
step, an undo, a new draft and an opened tour each lay them in with one
call, and useTourPlanner is back under the size limit.
2026-10-20 18:21:46 +02:00
Maurice fb208a88e2 test(tours): read the MCP fields and stage edits the way the other tests do
The confirmed field is checked through z.object(...).pick() as the dawarich
and doc-sync tests read their tool shapes, and the stage edits pass typed
waypoints through the request contract as the walk tests do. The previous
forms sent the test typecheck out of memory.
2026-10-20 18:19:00 +02:00
Maurice 5ae8ac8e70 test(tours): type the tours tests against the current contracts
Raw request bodies are objects, the collection snapshot names the run, ski,
winter and track time fields, the walk write says it has no hiking limit,
the MCP field shapes are read as zod shapes, and the stage edits go through
the tour request contract the API parses them with.
2026-10-20 18:05:41 +02:00
Maurice 20bd384feb fix(tours): send a ski import's technique only when one is chosen
The classification request may clear the technique with null; the GPX
import fields take it or leave it out, so the null never reaches them.
2026-10-20 18:05:40 +02:00
Maurice 58302dc9e4 fix(tours): keep the planner page hook's view model its only object return
The map focus helpers split out of useTripPlannerPage returned object
literals ahead of the hook's own return, which the B4A wiring test reads as
the view model. They move to tourMapFocus.ts beside the hook.
2026-10-20 18:05:40 +02:00
Maurice b460cfd9a7 refactor(tours): move non-component exports out of component files and split long hooks
Clears the client lint ratchet: helpers move to sibling .ts files for react-refresh, complex render paths get small helpers, floating promises are voided, the stage rail lets a stale request go during render, and the collections page reads the active list's tours and rights in ActiveCollectionTours. StageNumber is no longer exported.
2026-10-20 17:51:28 +02:00
Maurice 2b8d2e790f chore(shared): format the tours and wanderer locale and spec files 2026-10-20 17:51:27 +02:00
Maurice 2fc9b1991a test(tours): fix client test expectations and mocks
The type order puts run before kayak, the import button reads Import GPX or KML, and the mocks gain what the components now read: routeRun, packing items and links, the map's zoom, the translation provider and the compare store's map flag.
2026-10-20 17:51:27 +02:00
Maurice 4d81180fc1 test(tours): bring the server tests in line with the tours board
Expectations follow createAssignment's options, the stage_ends column, the editor's trip access and the broadcasts of the take-over. Refusals in the run and ski e2e tests go one by one so supertest does not reset a connection under another request. A parity test keeps the source hint enums in step with the contract.
2026-10-20 17:51:27 +02:00
Maurice 0f600d04f5 fix(tours): import the walk icon, keep one stop menu open and count only stage ends a change lost
The activity section used Footprints without importing it. Opening one stop's menu now closes any other. lostStageEnds no longer counts a stop the change kept without its mark as lost twice over. The wanderer watch switch keeps the same tree once the connection is known, so it is not remounted and keeps its focus.
2026-10-20 17:51:26 +02:00
Maurice b53257deaf fix(tours): answer a new tour with its activities and type the change save's outcome
createTour now answers activities: [] as the detail read does, and the change save's transaction declares the shape it returns so the strict typecheck can narrow it.
2026-10-20 17:51:26 +02:00
Maurice b284095ee0 fix(tours): count a multi-day tour in the activity sums only once all its stages are on days
The activity sums never read which stage a day holds, so a multi-day tour
counted its whole route on the last dated stage day while other stages
were on no day yet, and a recording on one stage took the place of the
whole plan beside it. With stages on days of their own, the route now
counts once every stage is on a dated day of the user's, on the last of
them, and is uncounted until then; a recording counts as what was done
and the plan is not added beside it.
2026-10-20 17:00:27 +02:00
Maurice e40e576e39 fix(tours): preset a ski tour's actual track as ski, and cover the run and ride presets
Ski has been an importable type since SKI-02, yet a ski tour's recording
still opened in the GPX preview as a hike. It now opens as ski with the
tour's discipline, and its technique for cross-country. The run profile
fallback and the ride preset get their tests.
2026-10-20 16:57:27 +02:00
Maurice 4d9b84f681 test(tours): give the packing link copy test an id of its own
TOURREPO-012 was used twice in the tours repository test.
2026-10-20 16:56:56 +02:00
Maurice 223b056de2 fix(tours): name a round hike's distance bounds {from} and {to}
The three round hike strings passed formatted distances as {min} and
{max}, and {max} is a quantity param, so i18n parity read them as count
strings outside a plural group and failed. The bounds are now {from} and
{to} in every locale and in the card.
2026-10-20 16:56:45 +02:00
Maurice 981c372640 fix(tours): let only the latest read set a collection's kept tours
Two reads of the same collection, after two changes by other members or
the first read overlapping one, could answer in either order and an older
list replace a newer one. A refused removal also restored the list from
before it, dropping changes read meanwhile; it now only puts its own tour
back.
2026-10-20 16:56:15 +02:00
Maurice 839d736905 fix(atlas): keep tour exclusions right when their answers come back out of order
Each exclusion replaced the whole list with its answer, so two quick
exclusions answered in the other order, or a read answered after an
exclusion landed, put back a tour as counted although the server held it
excluded. Only the answer to the latest request is now taken whole, with
the exclusions still on their way laid over it; an older answer only
confirms its own tour.
2026-10-20 16:55:34 +02:00
Maurice b9391a9921 fix(tours): hold an actual track change back while the tour is offline or queued
Linking or unlinking a recorded track went out even in offline mode and
behind a queued write of the same tour, and its answer replaced the whole
cached tour with the server's older state, losing the local change. Both
now refuse as online only in those cases and for a tour the server has
not got, the view says so, and the answer only updates the cached
tour's recordings.
2026-10-20 16:54:35 +02:00
Maurice 5e0f216b0c fix(tours): name an actual track's metrics and a tour's stages as derived in get_tour_plan
What a recorded track measures is read off its line on every read, and a
multi-day tour's stage count and stops are counted from its stage ends,
yet derived did not list them, so an assistant would take them for saved
facts.
2026-10-20 16:52:08 +02:00
Maurice a249c861e6 fix(tours): read and write a run's pace in the user's distance unit
The pace was always min/km and the run's time sum forced kilometres, so
with distances in miles the run card disagreed with every other figure in
the planner. The pace is still kept per km, but is typed, shown and
checked per mile when distances are in miles, and the time sum reads in
that unit too.
2026-10-20 16:51:34 +02:00
Maurice 2a40b884a1 fix(tours): keep a kept tour's remove button in sight, dimmed
It was invisible until hover or focus, so a phone or tablet had no way to
find it. It now shows dimmed and comes up fully on hover or focus.
2026-10-20 16:48:09 +02:00
Maurice f5be556c27 fix(tours): list a kept tour with its type's marks, pace and stages
The collection copy keeps the winter, run, ski, bike and stage columns,
but its list item only carried the older fields, so a kept ski track lost
its discipline mark, a snowshoe hike its winter mark, a run its pace and
a multi-day tour its stage count. The item now carries them, and a kept
tour's card shows the pace and the stages beside its measures.
2026-10-20 16:48:00 +02:00
Maurice 6b1488994f fix(tours): give an actual track its own mark and a winter tile its label's icon
Footprints stood for a snowshoe hike, an actual track and a walk's moving
time at once, with the snowshoe pill and the track pill side by side in a
tour's head. An actual track now goes by the done mark on the list row,
in the head and on a recording's tile, and a winter tile's corner takes
the icon its head pill shows.
2026-10-20 16:46:08 +02:00
Maurice 2ee4ea1e65 fix(tours): keep a multi-day tour's days in sight in the list row
The stage count sat in the row's fixed-height line of badges, so a tour
with stages and a recorded track wrapped and lost its days badge. The
count now sits on the fold button, a moon with the number at its corner,
or beside the row where it does not unfold, and the days come before the
measures so the ascent is what gets cut.
2026-10-20 16:44:55 +02:00
Maurice a1d683d7bc fix(tours): show a run's ways in the hike's trail details card
A run described its ways in a card of its own, with the bike's sections,
the bike's paint for the SAC grades (T5 and T6 merged) and its own words,
so the same path read differently on a hike and on a run. The run now
uses the hike's card, matched as a run, and keeps only the unpaved share
and the hardest grade in its head. The run's grade labels and the now
unused SAC paint go.
2026-10-20 16:42:47 +02:00
Maurice ae726ed370 fix(tours): time a run's stages at its pace and leave a ride's stages untimed
A multi-day run's stages took a share of the foot router's time while the
run itself is timed at the runner's pace, so the stages did not add up to
the run. A ride has no time at all, so its stages have none either.
2026-10-20 16:38:42 +02:00
Maurice f4750352e6 fix(tours): fit the merged tracks together after integration
Types, test expectations and helpers the seven tracks each changed on their
own, brought in line with the integrated code. tours.service.ts and the tours
repository grew past the size limit with every track and hand their row and
write helpers to modules of their own.
2026-10-20 16:30:55 +02:00
Maurice 65ee472d66 chore(db): regenerate entities and kysely types for the tours board 2026-10-20 15:40:31 +02:00
Maurice 73fa7aeb19 test(tours): give the tests the merged tracks added side by side ids of their own
Runs, winter hikes and ski tracks numbered their ToursRepository tests 016
to 018, as multi-day tours did; the multi-day list_tours tests took
TOURS-MCP-015 and 016 next to get_tour_plan's. They now read TOURREPO-022
to 024 and TOURS-MCP-024 and 025, so every id names one test.
2026-10-20 15:38:07 +02:00
Maurice fbba4fd73b fix(tours): refresh the wanderer watch after a fixed token and truly disable its switch offline
The watch row only re-read its state when the connection came or went, so after
a passing connection test or a newly saved token it kept saying "Token refused"
until a reload. The connection card now hands it a key built from the saved
connection and its last check, and the row re-reads the watch whenever that
changes.

Without a connection the switch was only dimmed: the button stayed in the tab
order and was announced as active. ToggleSwitch gains a disabled prop that
disables the button and marks it aria-disabled, and the reason stays reachable
through a focusable wrapper carrying the tooltip.
2026-10-20 15:35:32 +02:00
Maurice 2e1dca9fe9 feat(tours): let a background check hint at changes of your own wanderer trails
Settings, Integrations, wanderer gains an optional background check of the
trails you imported, off until you switch it on. While on, TREK reads the
short record of each such trail at most once a day (never its GPX file) and
leaves a hint when the trail may have moved on: "may have changed" when only
wanderer's change time moved, "changed" when its name or description proves
it, and "not checkable" when the trail is gone, no longer yours or carries no
change time. Nothing is ever said to be unchanged. A new hint arrives as a
notification that opens the tour (a new ?place= link in the planner); the
tour's provenance shows it with the source check right beside it, and the
card lists all open hints in a dialog, each leading to its tour. The check
reads the source in full as before and spends the hint; taking a change in
stays the confirmed reimport. No tour is ever changed or deleted.

The check only asks through your own connection, only about trails your
account imported into trips you can still open, and stops when you switch it
off, remove the connection, wanderer refuses the token (until a test passes
or a new token is saved) or the admin switches wanderer off; hints and
snapshots stay. A run asks at most 25 connections for at most 10 records
each, one after another, and an instance that fails (an outage, a rate
limit) is left alone for an hour, doubling up to two days.

Server: migration 046120 adds the switch and the run bookkeeping to
wanderer_connections and the tour_source_hints table (one row per snapshot,
for its importer, gone with it). WandererWatchService and an hourly
wanderer-watch job on the CronRegistrar live in the tours domain and read
through WandererService; GET and PUT /api/integrations/wanderer/watch and GET
/api/trips/:tripId/tours/wanderer/sources/:placeId/hint (place_edit, like the
check) answer with @trek/shared contracts. A new user notification event,
wanderer_source_hint, can be muted per channel. Saving a new token now clears
what the last check said about the old one. There is no MCP tool, as for the
rest of the personal wanderer connection. All 27 locales carry the strings,
and the admin wiki page says what the check does.
2026-10-20 15:35:32 +02:00
Maurice aacc7f2c8d fix(tours): open a tour's packing list on Packing, and ask before letting go of it while Packing is off
"Open packing list" switched to the Lists tab but left it on whichever
sub-tab was open last, so someone who had been in the to-dos landed there.
It now picks the packing side first, and a Lists tab already on screen
follows the choice; storage that is blocked no longer throws either.

With the Packing addon off, "Remove link" let go of the link on one click,
and the link cannot be made again until Packing is back on. It now opens
the tour's packing dialog with only the dimmed link and the Remove link and
Cancel buttons, so nothing changes without a second, deliberate click.
2026-10-20 15:35:09 +02:00
Maurice f833a05461 feat(tours): point a tour at its trip's packing list
A tour can now point at the packing list of its trip, as a whole or at one
of its categories (say "Hiking gear" for a hike), and shows it in its detail.
The new Packing list section reads the list live as the user may see it: the
backpack tile with the item count on its corner and the category's own
colour, how many are packed with a thin progress bar, the first items ticked
off or not with their quantity, how many more there are, and a button that
opens the trip's Lists tab. Who may change the tour links one, changes it or
lets go through a dialog that lists the whole list and every category with
its colour and item count, and writes only on its confirm.

It is a reference, never a copy: the items stay in Packing, are ticked off
and edited there, and each traveller sees in the tour only the items Packing
shows them, so an item kept to oneself never shows through a tour. A
category that is renamed, cleared or only holds items of others reads as
missing, an empty one as empty, a link that could not be read says so with a
retry, and while Lists are switched off the link stays, dimmed, and can
still be let go. Removing the link, or the tour, leaves the list and every
item as they are. Nothing suggests or changes an item by itself.

On the server the link is a row of its own in the tours domain
(tour_packing_links, migration 046110), one per tour and gone with it.
GET, PUT and DELETE /api/trips/:tripId/tours/:placeId/packing-link read it
with the trip and change it with place_edit; linking needs the Lists addon
and, for a category, an item of it the user can see. The writes answer the
tour with its link and tell the trip through tours:changed, so the offline
client queues them as writes of the tour and replays them like one. A copied
trip keeps its tours' links, a tour saved as new or taken over does not.
The MCP tools get_tour_packing, link_tour_packing and unlink_tour_packing run
over the same service and checks; the read also hands the tour's facts as
context for suggesting what to pack, which only becomes an item through the
packing tools. The empty category placeholder now lives in @trek/shared so
both sides leave it out alike. All 27 locales carry the new strings, and
the wiki describes the link and the tools.
2026-10-20 15:35:08 +02:00
Maurice 65fe8cd6e5 fix(tours): explain a kept tour without a line, and say the take-over once
Taking a tour kept in a collection into a trip left the user guessing in
two places when the tour had no line. The map box stayed an empty grey
square that read as a broken map; it now says that there is no line yet,
only the waypoints. Why "Line only" is dimmed was only in the tooltip of a
disabled button, which never shows on hover, focus or touch; the reason now
stands under the two choices, and a disabled tile sits in a span so its
tooltip can show as well.

A successful take-over said the same sentence twice, as a toast and as the
dialog's last word with "Open trip". The toast is gone.

The collection strings move out of tours.ts into a tourCollection.ts per
locale, keeping their keys, so no locale's tours file grows past the
1000-line limit. The tours locale spec now holds both files to their own
counts.
2026-10-20 15:34:09 +02:00
Maurice 3fe34061fd feat(tours): keep whole tours in collections and take them into trips
A saved tour can now be kept in a collection, apart from any trip, and
later made into a tour of another trip. The bookmark button in a tour's
footer and in the planner's head (waiting for the save while there are
unsaved changes) opens a dialog with every list of the user; a list they
may only read stays in it, dimmed with a lock and a tooltip. The list
keeps an independent copy: the line, the type, every measure and setting,
the waypoints in order with their roles, the origin, and the trip and tour
it was saved from as they were called then. Its days, its links to places
of the trip and its obstacle decisions stay in the trip, and later changes
to the tour do not reach the copy.

A collection shows its kept tours as cards above its places: the grade
tile with the source badge, measures and the waypoint count as badges,
the trip it came from, and who kept it. The owner or an admin of the list
can remove one after a question; tours made from it stay. "Add to a trip"
shows the kept tour on a map with its facts and numbered waypoints, lists
the user's trips (the ones they may not add tours to dimmed), and asks
outright whether the waypoints come along. "Line only" keeps the line as
saved and adds no waypoints; a tour kept without a line can only bring
them. The new tour has an id of its own, no days and no new places, and
names the trip and tour it was saved from; the dialog then offers to open
that trip. Neither the collection nor the original tour changes.

On the server the kept tours are a table of their own in the tours domain
(collection_tours, migration 046100), each row written whole once. Every
copy, the in-trip "save as new tour", the take-over from another trip and
both directions here, now goes through one copy model (tour-copy.helpers)
instead of a second one. Reading and removing follow the collection's
roles, keeping one also needs access to the source trip, and taking one
over needs place_edit on the target trip; the kept tour is read again
inside the write, so one removed meanwhile leaves no half-made tour. The
MCP tools list_collection_tours, get_collection_tour,
save_tour_to_collection, take_over_collection_tour and
delete_collection_tour run over the same service and checks. All 27
locales carry the new strings, and the wiki describes the flow.
2026-10-20 15:32:47 +02:00
Maurice 3659297af1 feat(tours): import KML and KMZ routes as tours, with a preview first
A route planned in Google My Maps or Google Earth can now become a tour
without converting it to GPX first. The import button and a file dropped on
the tours workspace take .kml and .kmz files beside .gpx, through the same
preview, the same import routes and the same transaction as a GPX file.

The preview shows every line of the file on its own, numbered, with its
distance and heights; two lines of one placemark are numbered after its name
and never joined. Heights count only where they are heights: kept for an
absolute line, dropped for any other altitude mode and for the all-zero
placeholder My Maps writes, so a missing height stays unknown. What the file
holds besides its lines (pins, areas, overlays, links to other files) is
counted in a dashed card, and nothing is fetched from elsewhere.

The KML reading reuses the place import's codec. A KMZ only ever has its KML
entry unpacked, counted byte by byte against the decompressed cap whatever
its header claims. A file that cannot be read is refused with its reason
(damaged, an archive without KML, too large unpacked), and the toast names
the file and the reason. A KML or KMZ import keeps the gpx origin of a file
import; its provenance names the real format from the stored file name.
2026-10-20 15:30:18 +02:00
Maurice f57a0dbd31 fix(tours): let an unwanted stage go and read a tour's stages once
Two review findings of MDT-02 in the planner's list and the day plan:

- A stage asked for while the draft has unsaved changes stayed pending when
  the changes were kept or the tour failed to open, so opening that tour
  later from its row jumped to a stage nobody asked for then. The rail now
  has cancel(), which the discard question calls when it is dismissed and
  the open calls when it fails, and it lets a stage go once the opened
  tour's points are in and none of its stages starts at that stop.
- Every day holding a stage of a tour read the whole tour on its own, so a
  five stage tour sent five identical requests on top of the list's own.
  tourPointsRepo shares one read per trip, tour and revision among the
  views showing it at the same time, through the tour repo as before, and
  drops it once the last view lets go or the read fails.
2026-10-20 15:28:23 +02:00
Maurice 3748bdb815 fix(tours): keep the day assignments repository under the size limit
The stage queries of MDT-02 took DayAssignments.repository.ts past the
1000 lines lint:size allows. Two concerns move out of it instead:

- The stages of a multi-day tour that days hold are a tour question, so
  findTourStage and deleteTourStagesNotIn move to ToursRepository, next to
  isOnDay and listStagePoints, which AssignmentsService already asks. No
  constructor changes, and the tests move with them (TOURREPO-019 to 021).
- The rows the repository answers with and the Kysely table shapes its
  statements are typed against move to day-assignment-rows.ts, so the file
  holds the statements alone and has room again.

tours.service.ts stays at its 1000 lines.
2026-10-20 15:28:23 +02:00
Maurice 8fa75b3c4e feat(tours): show a multi-day tour's stages in the list and put each on a day
A multi-day tour in the planner's list of the trip's tours now unfolds into
its stages (MDT-02), so the stages can be seen together and fitted into the
trip without leaving the one route they are cut from.

- The fold beside a multi-day tour lists its stages in route order, each
  from stop to stop with its distance, climb, descent and moving time, read
  off the saved tour the way the planner reads them, and below them what the
  whole tour adds up to. A value no stage tells is left out, a total some
  stages do not tell is marked as at least that much and says for how many,
  and a stage whose route is not planned yet says so.
- Each stage has its own day: a pill opens the trip's days, moves the stage
  to another one or takes it off again. A stage without a day shows a dashed
  "No day" pill. A day holds a tour, or one of its stages, once, and a stage
  is on one day at most; the plan's own offline path writes it.
- A stage opens in the planner: the tour opens as any tour does and the
  stage is shown once its points are in.
- The day plan names the stage a day holds with its own figures, and the
  day's route runs to that stage's start and on from its end; the trip map
  draws that stretch only. The tour list still counts a tour once, however
  many days hold it or its stages.
- A saved change that takes a stage away (its stage end gone, the tour a day
  tour again, back to the recorded track) takes it off its day in the same
  write and tells every client; the planner says so before the save.
- REST and the assign_place_to_day tool take the stage by the key of the
  stop it starts at, with the same checks; list_tours names the stops
  between the stages and the stage each day holds.

A day assignment remembers its stage by that key (migration 046010); every
assignment made before holds the whole tour, as it did.
2026-10-20 15:28:22 +02:00
Maurice 6ec06c90f4 fix(tours): keep a lost stage end visible on paddle tours
A stage end set on a lock or portage point the water router placed left
the point automatic, so the router could drop it with its leg later,
silently and without an undo step. Ending a stage there now makes the
point the paddler's own, like any other edit of it.

The notice for stage ends a change could not keep also vanished as soon
as the router laid its points in again after the change. It now stays
until the next change the user makes, an undo or redo, a save, or
another draft.
2026-10-20 15:26:43 +02:00
Maurice 2449616ee9 feat(tours): plan a multi-day tour as one route split into stages
A hut-to-hut hike or a paddle trip over several days is now planned in the
Tours planner as one tour: the whole route is planned as before, and stops
along it are marked as stage ends (MDT-01). The stages are read off that one
route in its order, never kept as copies, so a change inside a stage changes
the whole tour and every figure on it.

- A Day tour / Multi-day tour switch beside the tour type; a stop's menu
  ends a stage there or lets it go on. Back to a day tour removes every stage
  end after a confirmation, as one step undo brings back.
- The list of points is headed per stage with its distance and moving time;
  a stop that ends a stage carries a moon at its number, on the map too.
- The overview lists the stages from stop to stop with distance, climb,
  descent and time (hiking rule per stage, paddle estimate per stage, the
  router's time shared by distance otherwise), marks where each ends across
  the elevation profile, and a stage of more than a day on the move warns.
- A stage end a change cannot keep (its stop removed, moved to an end or
  made a shaping point) is dropped and said so, with undo, never moved
  to another point.
- A multi-day tour may take a day a stage, so its time limit grows with its
  stages; the tour keeps its one type, portages included.
- The list and the MCP tools show the stage count and where stages end.

Stage ends are a flag on the waypoint (migration 046000); a tour without one
is a day tour, as every tour saved before.
2026-10-20 15:26:18 +02:00
Maurice acc294dd8e fix(atlas): count shared days for every member, keep routes and multi-day tours honest (STA-02)
A day of a tour that names nobody now counts for every member of the trip,
not only for its owner. In the planner an empty participant list means
everyone, and a full member list is stored back as empty, so members got
nothing for most shared tours. Only a day that names participants without
the user stays theirs. The tours of a shared trip on no day or only on
undated days are now named as not counting for members as well.

A whole route a multi-day tour's stages were planned from is no longer
dropped on the server as soon as any stage has a day. Each entry carries
derived_from_place_id and the shared summary leaves the route out only
while one of its stages is done, counted and in the period and type. A
route walked last year now still counts when a stage of it is planned for
next month or was left out because it fell through, in the Atlas and over
MCP alike.

A tour on several days is dated on the last of them, as is a recording on
no day, so it no longer counts while it is still under way.

The info text, the get_tour_activity_stats description and the wiki say so.
2026-10-20 15:23:55 +02:00
Maurice bde2ad2ca8 feat(atlas): sum up the tours you have done, by period and type (STA-02)
With the Tours addon on, the Atlas panel gets a third tab, Tours, beside
Stats and Bucket List: what you have done on your tours across all your
trips. A period (all time, this year, the last 12 months or an earlier year)
and a tour type narrow it; the tab shows the distance large with the number
of tours at its corner, the ascent and descent, and the types with their
distance. "Show tours" opens every tour of the period in a dialog with the
same filters, the sums, the split by type and one row per tour.

What counts is said, never guessed. A tour counts once the day it is on is
over, with the values of the planning, badged "Planned" and explained as
not measured. A linked actual track (ACT-01) replaces those values for its
execution, badged "Recorded" and named, and is never added on top nor
counted as a tour of its own. A tour on several days counts once, a whole
route does not count beside the stages planned from it, a paddle tour sums
no heights, and a value nobody knows stays out of the sum, which then reads
"at least". Only the user's own part counts: a day names them among its
participants, or names nobody on a trip of their own; being able to open
somebody else's trip makes none of its tours theirs. Tours still ahead, on
no day or only on undated days are named as not counting. A tour that fell
through can be left out of the sums ("Leave out"): it stays in the plan
and in the list, dimmed, and "Count again" brings it back. Only the user's
own numbers change.

Server: GET /api/tours/activity-stats answers the user's activity entries
across every trip they can open, and PUT /api/tours/activity-stats/exclusions
leaves a tour out or counts it again (the tour must be in such a trip, else
404). A tour_activity_exclusions table keeps the exclusions per user and
tour. The sums are added up by a shared summary in @trek/shared, so the
Atlas and the assistant say the same numbers. MCP: get_tour_activity_stats
and set_tour_activity_excluded over the same service. The tab reads live
and needs a connection, like the rest of the Atlas statistics.
2026-10-20 15:23:55 +02:00
Maurice ae79ab8dad fix(tours): offer the map comparison only where the plan map can draw it (ACT-02)
The compare switch in a tour's detail showed whenever the detail sat beside
the plan map, but the map draws no comparison in road trip mode or with the
trip overview open. Pressing it there turned the switch on while nothing
appeared on the map.

The plan map now tells the compare store whether it can draw a comparison.
The detail offers the switch only then, and turning to the road trip or the
overview, or leaving the map, ends a running comparison.
2026-10-20 15:23:18 +02:00
Maurice 6a0c54cb2f feat(tours): compare a tour's plan with the track it was actually done with (ACT-02)
Each actual track in a tour's detail now stands beside the plan instead of
on its own: a table names the plan and the actual track as columns, each with
the line it is drawn as on the map, and sets distance, ascent, descent and the
time with breaks side by side. The difference is shown, signed and with an
arrow, only where both values are known and mean the same thing; a planned
total that leaves a stay out reads "at least" and is not subtracted from. A
value one side does not carry is named as missing ("Not in the plan", "Not in
the track", "No time stamps"), never estimated. Below the table, how far the
track left the planned line: its farthest point and the recorded stretches
more than 50 m away, or that there is no line to compare.

On the desktop plan map, "Compare on map" draws the actual track dotted in
the colour of done next to the plan, which stays picked in its own track
colour (and is put back should it be hidden on the map). The recording leaves
the map as a tour of its own meanwhile, so it is never drawn twice or in a
plan's colour. A frosted legend over the map names both lines and ends the
comparison; so do closing the detail, comparing another track, or the track
being unlinked. Comparing only reads: neither the plan nor the track changes.

The track is laid into the day's route lines the map already draws, so both
renderers show it without a layer of their own. distancesToLine in the shared
route corridor measures many points against one line laid flat once; a long
recording is measured in at most 1500 samples.
2026-10-20 15:23:18 +02:00
Maurice a8be1c5b45 fix(tours): keep unknown actual tracks unknown and name every track a link replaces (ACT-01)
A tour detail that cannot be read (offline without a cached copy, or a server
error) no longer shows "No actual track yet" while the trip list names tracks
of the tour; the section says they could not be loaded and offers no link over
them.

The day a track was recorded on is the viewer's calendar day of its first time
stamp, not the UTC one, in the tour detail, the link dialog and the GPX
preview alike, through one helper.

Taking a tour off a day keeps its actual track without a day, so "no day" can
hold several. A change now starts on the track it was asked for, and the link
dialog names every track it replaces, as the server clears all of them. The
migration, repository, service and MCP texts say so.

The refusal for linking a tour to itself or a track with tracks of its own now
reads "This track cannot be linked to this tour", which is true of both, and
the offline helper of the tour cache sits above the doc of replaceTripTours
again.
2026-10-20 15:23:10 +02:00
Maurice d0cc6fc2f4 feat(tours): link the track a tour was actually done with, and show its own values (ACT-01)
A planned tour can now be given the track it was actually done with. In the
tour's detail, "Add actual track" opens a dialog that asks for the day the
tour was done on (or none) and a recording of the trip: an import nobody
planned over, or a new one through the existing GPX import, its preview
preset to the tour's type. What the choice measures is shown before anything
is written, and only "Link" writes. The linked track keeps its own name and
source; the detail shows its distance, heights, profile and, only where the
file carried time stamps, the time it took, marked as actual values next to
the untouched plan. A track can be changed or unlinked again.

Server: a tour_activities table points a tour's execution (a day assignment,
or none) at a recording of the same trip, and the tours GPX import keeps the
first and last time of a track whose every point carries one. The values are
read from the recording's own line on every read, so a changed recording
updates them while the plan's line, measures and revision stay as they were.
Every check runs inside the write: the recording must be an import of this
trip not planned over, documenting no other tour; the day must be one of this
tour; a refusal names its reason and changes nothing. PUT and DELETE
/api/trips/:tripId/tours/:placeId/activities answer the detail, which now
carries the actual tracks; the list names them per tour and per recording.
MCP: assign_tour_track and unassign_tour_track over the same service and
checks, get_tour_plan reads the values without the line.

A recording linked as a tour's actual track is no longer counted as a tour of
its own in the trip's tour summary, and the GPX preview imports a city walk
as one instead of as a hike.
2026-10-20 15:22:55 +02:00
Maurice f016972a4e fix(tours): explain and offer to re-ask hike ways that could not be asked
A way OpenStreetMap could not be asked about this time now carries a tooltip
saying so, and the unknown list offers to ask again whenever such a row is
there, not only when nothing at all was answered.
2026-10-20 15:19:11 +02:00
Maurice c80152f41d fix(tours): keep a non-canonical sac_scale as written instead of lowercasing it
A value such as Alpine_Hiking is not one of the six SAC values word for
word, so it now reads as an unclear grade with the raw value OpenStreetMap
holds, rather than as a known T4 the planner normalised itself.
2026-10-20 15:19:03 +02:00
Maurice f745ca13c9 feat(tours): suggest round hikes of about a wished length from a stop (RTE-03)
A hike can now be planned as a round out of one of its stops without
building the loop point by point. "Round hike from here" in a stop's menu,
or the round button in the head of the waypoints for the start, opens a
card over the planner map: the start (any stop of the tour, picked from a
list), the wished length in the traveller's unit, the window of a fifth
either way it allows, and the tour's hiking limit, which every question
to the router keeps to together with the hike's preferences.

TREK looks for up to three different rounds itself on the hiking router it
already uses. It steers loops round circles in several directions from the
start, asks a loop that missed the length once more at a corrected size,
and learns from each answer how much longer the paths are than straight
lines. A loop that walks most of its ground twice (a way there and back), a
spot too far from any path, and a round that repeats one found are left
out, and nothing outside the window is offered. The rounds show up in
their colours on the map and in 3D as they are found, the map frames the
area they can lie in, and each lists its length, how far it lies off the
wish, climb, descent and walking time once the heights are in. Fewer
rounds than three, none, or a router that does not answer are said so.

Showing, choosing and closing change nothing. "Take this round" puts the
start, the steering spots as shaping points and the way back in place of
the plan as one step of the history, with a "Round taken" note and its
undo; a tour that held more than its start asks first. The round is then
an ordinary tour to edit and save. A start moved or a limit changed while
rounds are shown lets them go and the card says why.

The card shares its frame, buttons and rows with the comparison of a
section's ways, and the map corner still holds one card at a time.
2026-10-20 15:19:03 +02:00
Maurice 4f68029b2c fix(tours): keep one card in the map corner and offer only ways that change a section
While the other ways of a hike's section were compared, a click on a via
ferrata or a place on the map opened its card in the same corner, stacked
over the comparison. The corner now holds one card: whichever opened last
stays and the other closes, before paint, so the two never show together.

The router's first answer between the two ends is its own way under the
hike's rules, which the tour already routes the section by. Offered as a
choice, taking it only planned the leg again, usually to the same line, and
the card closed without "Way taken" or an undo step. It is no longer
offered, so every way taken is held by shaping points, changes the route and
can be taken back in one step.
2026-10-20 15:18:48 +02:00
Maurice 64445a5204 feat(tours): compare other ways for one section of a hike (RTE-02)
A routed section of a hike between two neighbouring points can now be
compared with the other ways the router knows between the same two points,
under the same limit and preferences. "Compare ways for the next leg" in a
waypoint's menu opens a card over the planner map: the way taken now and up
to three others, each drawn on the map in its own colour, with length,
climb, descent and walking time by the signposts' rule. Figures without
heights stay unknown, and the differences to the current way are shown
only where both are known. When the router knows no other way, or does not
answer, the card says so.

Showing, hovering and choosing change nothing. Only "Take this way" does:
the router is first asked whether a few shaping points hold the chosen way
(the check the road trip's picker already uses), and only then are they set
into that one leg as a single step of the history, with a "Way taken" note
and its undo at the foot of the map. Other legs, fixed lines, days and
points stay as they were, the totals follow from the replanned route, and
the shaping points can be dragged like any other. A section that changed
while the router was asked, or a way it does not keep to, is not written,
and the card says why. The ways are drawn in 3D too.
2026-10-20 15:18:38 +02:00
Maurice bff9bcde92 fix(tours): read ways past the grade cap as not asked instead of untagged
A long hike or mountain bike tour can run over more distinct ways than one
grade request may ask about. The ways past that cap were never asked, yet the
facets read them as carrying no surface, way type or grade in OpenStreetMap.
They now land under 'Could not be asked this time' with the unanswered ones.
2026-10-20 15:18:01 +02:00
Maurice 3572f4b8a3 feat(tours): show the known surfaces and way types along a hike (MAP-04)
The hike's "SAC grades" card becomes "Trail details": one switch, then a
segmented choice between SAC, Surface and Way type, three things kept
apart and drawn along the route on the planner map one at a time. Every
facet reads from the same answer: the ways under the line are matched on
foot once, their OpenStreetMap values fetched once through the
trail-grades read MAP-03 already uses, and each stretch is placed against
the tour's own line once. Switching facets asks nothing new.

The server now hands on a way's surface and highway tags as OpenStreetMap
wrote them, only trimmed and bounded. The planner groups them (asphalt or
concrete, paving stones, compacted, gravel, natural ground, grass, rock,
wood or metal, unpaved without a kind, other; path, footway, steps, farm
or forest track, cycle path, minor road, road, via ferrata, other), and
each row's tooltip names the values recorded, such as surface=gravel,
surface=pebblestone. The router's own surface reading is never used: it
is normalised and a default where nothing is recorded.

What is not known stays unknown and says why, in every facet: no value
recorded, a value no group takes or two at once (unclear, never the
nearest group), no way clearly under the tour's line, or a way that could
not be asked about. Imported tracks and hand-drawn stretches keep their
geometry; a matched way only counts where it keeps within 25 m of it.
A summary badge gives the known share and the most common class; the SAC
facet keeps its highest step and routing limit badges. Nothing about the
tour, its places or its days changes, and no routing preference is set.
2026-10-20 15:17:52 +02:00
Maurice ea9e997338 feat(tours): show the known SAC grades along a hike (MAP-03)
A hike in the planner, being edited or viewed as an import, now has an
"SAC grades" card. Switched on, it asks the router which OpenStreetMap
ways lie under the line (matched on foot, every grade of path open) and
the server reads their sac_scale through the Overpass mirrors, the same
trail-grades read the mountain bike grades use. The grades are drawn
along the route on the planner map in one ramp of a single hue, T1 to
T6, and listed by share and length; a row draws its class alone. Each
row names its source in a tooltip: the SAC hiking scale and the
sac_scale value OpenStreetMap wrote.

What is not known stays unknown and says why, never T1: a way without
a grade, a grade that names no single step (handed on as written), a
stretch with no way clearly under the tour's line, or a way that could
not be asked about. For imported tracks and stretches drawn by hand a
matched way only counts where it keeps within 25 m of the tour's own
line, so the original line is kept and nothing is guessed. The router's
own reading of the scale is not asked for, since it may be normalised.

The routing limit of a planned hike sits apart as a setting with its
own badge, and a known step above it is marked. Nothing about the tour,
its places or its days changes; switched off, the map is as it was.
No regional colour categories and no grade derived from slope, height
or trail markings.
2026-10-20 15:17:23 +02:00
Maurice ad2d653e9a fix(tours): save_tour_change says nothing was saved and answers a proposal saved once already
A save refused by the rules of the tour now says "Nothing was saved and
nothing changed." instead of the preparation's wording: lay() takes what
the changes are laid for, and refusalText() leads with that action.

A save sent again after it committed (a retry after a lost answer) used
to meet the conflict of a tour that moved on, which tells the assistant
to prepare the change anew, so an add could be saved twice. The save now
looks up the step its proposal was saved as from the revision it was
prepared on, before the stale check and once more inside the write, and
answers already_saved with that step instead, writing nothing.
2026-10-20 15:14:30 +02:00
Maurice b177daea3c fix(tours): point undo_tour_change at the revision prepare_tour_undo returns
The expected_revision field told assistants to read now.revision of the
preview, but prepare_tour_undo never returns that field: the revision sits
at undo.expected_revision. An assistant following the description either
guessed or left the value out and ran into a validation error or a revision
conflict. The description now names the real field, and the schema spec
pins it.
2026-10-20 15:14:30 +02:00
Maurice 63a7a4d397 feat(tours): undo_tour_change takes back a saved change to a tour once the traveller confirmed it
An assistant can now take back a change it saved with save_tour_change,
the way the change was saved: listed, shown, then taken back only after
the traveller said yes. Until now a saved step could be followed but not
undone; the traveller had to rebuild the walk by hand in the planner.

- list_tour_change_steps lists the steps of a tour, the latest first, with
  the changes as confirmed and where each stands: latest, undone, later
  change on top or no readable earlier state. Planner saves are no steps.
- prepare_tour_undo shows what taking a step back does without writing
  anything: the tour now, with the stations visited or skipped, set
  against the tour exactly as the step kept it, the stations kept, coming
  back and removed, and the distance and walking time stored then.
- undo_tour_change restores that tour whole or not at all, through the
  write the planner's save uses: line, stations with their keys and the
  measures stored then, in one transaction with the mark on the step, and
  the trip is told after the commit. It needs place editing on the trip
  and checks everything again inside the write.
- Only the tour's latest change goes back. A step something was saved on
  top of since, in the planner or by another step, is a conflict on the
  tour's one revision (no second one), so no later edit is lost unseen; a
  preview read at another revision is a conflict too. Taking back a step
  that would remove a station visited or skipped since, or bring back a
  station whose place of the trip is gone, is refused with every reason,
  and so is a step without a readable earlier state. Every refusal and
  failure leaves the tour as it is.
- A step is taken back once: tour_change_steps records the revision that
  raised the tour to, who confirmed it and when, and nothing else is kept.
- None of it touches a planner draft: the planner's own undo stays in the
  browser, and discarding a draft never changes a saved tour. The tool
  descriptions and the wiki say so.

The write of a tour's line and points and the tour as the answer gives it
now come from one place for the save and the undo.
2026-10-20 15:14:29 +02:00
Maurice f4533fc69b feat(tours): save_tour_change saves a confirmed change to a city walk whole or not at all
An assistant can now save the change prepare_tour_change prepared, once the
traveller said yes to it exactly as shown. Until now a proposal could only
be looked at; the traveller had to redo it by hand in the planner.

- The save names the proposal: the same changes, the revision it was
  prepared on, the proposal_id the preparation answered and confirmed true.
  Anything else, a change altered on the way or no yes, is refused and
  nothing is saved.
- Place editing on the trip and the tour are checked again before anything
  is written. A tour changed since the proposal was prepared is a conflict
  on the tour's one revision (no second one), telling the assistant to
  prepare the change anew instead of writing over newer edits. The changes
  are laid on the saved tour once more inside the write, so a station
  visited or skipped meanwhile, or a place of the trip gone, refuses the
  save with every reason.
- The change goes through the write the planner's save uses, in one
  transaction: line, stations, keys, measures and visits together or not at
  all, and the trip is told only after the commit. Stations kept keep their
  keys and visits. Where the router gives no way on foot the walk keeps its
  stations only and waits for the planner, as a created one does.
- Every save is kept as a step of the tour (tour_change_steps): the
  proposal, the revision it was prepared on and the one it raised the tour
  to, the changes and the tour as it was before them, so the step can be
  followed and taken back later. Nothing of the conversation is kept.
- A new walk has no revision to name and stays with create_city_walk; both
  tool descriptions and the wiki say which is which.
2026-10-20 15:14:29 +02:00
Maurice 1993ed4d28 fix(tours): prepare_tour_change drops the shaping of legs it takes apart and guards a loop's closing leg
A prepared change to a city walk took a station out or put one in without
touching the shaping points around it. The way after the change was then
routed through points steering legs that no longer exist, so the walking
effects showed a detour that is not real, and the points handed on for a
save could start or end on a shaping point.

- A shaping point belongs to the leg it steers. Removing, replacing or
  moving a station drops the shaping points of its legs in and out, and a
  stop put after a station drops those of the leg it splits. Shaping points
  of other legs stay. The answer names every dropped one in data_gaps
  (shaping_dropped), and the walk always starts and ends on a station.
- The closing leg of a loop counts as the leg into its first station. A
  closing leg drawn by hand now refuses a change to the first station and a
  stop after the last one, instead of quietly moving the end of a leg drawn
  in the planner. The hand-drawn check also covers every sub-leg between two
  stations, not only the one next to the station.
- for_later leaves out a new station already linked to a place of the trip,
  so following it no longer creates a second copy of that place.
2026-10-20 15:14:01 +02:00
Maurice 1969ca57d9 feat(tours): prepare_tour_change prepares a change to a saved city walk for review
An assistant can now prepare a change to a saved city walk and show the
traveller what it would do before anything is saved. Preparing writes
nothing: no tour, station, visit or day plan changes, and turning the
proposal down keeps the plan as it was.

- The changes apply in order and name the saved stations by their keys:
  add a station after another (a spontaneous stop is an add after the
  station the traveller is at), remove, replace and move. Stops put after
  the same station keep the order they were given in.
- What the travellers did stays as it is: a station visited or skipped on
  any day of the walk is never removed, replaced or moved, nothing goes
  before the last station already done, a protected station keeps its
  place and a leg drawn by hand stays as drawn. A proposal breaking a rule
  is refused whole, naming every change and why. Other tour types, and a
  walk that is a recorded track, are refused too: so far only a city walk
  takes prepared changes, since the others plan their way with routing of
  their own the server cannot run.
- The answer sets the walk after the change against the saved one: each
  station with what changed it and where it stood, what was removed, the
  way on foot before and after planned by the same router (the stored
  distance and walking time given apart, never mixed in), the stays as far
  as known, every leg with the new ones marked, the gaps that could not be
  worked out, a warning for a must station removed or replaced, and each
  new station as create_place takes it, to keep it for later as a place of
  the trip instead.
- The proposal names the revision it was prepared on, so a save of it is
  refused once the tour has moved on, and says whether the traveller may
  change the trip's places at all.

The way on foot of a new walk, its creation and a prepared change now come
from one CityWalkFootWayService instead of a copy in the briefing service.
2026-10-20 15:14:00 +02:00
Maurice b826ecd425 fix(tours): get_tour_plan calls the leg of a recorded track a track, not routed
An import without control points of its own answers the two ends of its
recorded track as its points, and the one leg between them was labelled
routed, as if the router had planned it. The assistant read a false fact.
Such a leg now says track, and the tool description and the wiki explain
the kind.
2026-10-20 15:14:00 +02:00
Maurice 19946039ad feat(tours): get_tour_plan reads the whole saved plan of a tour
An assistant reading a tour over MCP now gets everything the saved plan
holds, from the same reads the app uses, so its suggestions start from the
actual plan:

- legs lists every step from one control point to the next with how its
  line came to be (routed, via ferrata, drawn by hand, portage), keyed by
  the points' stable keys, and the length of a fixed leg's own line; a
  routed leg has no length stored apart and says so with null.
- line gives the saved line in brief: its point count, whether it carries
  heights, and a preview thinned to 200 points; null while the way waits
  to be planned.
- A city walk can be read for one of its days (dayId): each station then
  carries its visit status that day and run what is still open. Without a
  day no visit is told, so one day's ticks are never passed off as the
  walk's; dayId on another type or a day the walk is not on is refused.
- derived names every field worked out on reading rather than stored,
  including the control points an import without its own answers from the
  ends of its track.

Reading still writes nothing: no revision moves and no visit changes, and
drafts in a browser stay invisible. The wiki row of the tool says the same.
2026-10-20 15:13:41 +02:00
Maurice 6577595058 feat(tours): take a recorded ride from a GPX file as it was ridden
A ride recorded elsewhere comes in through the one Tours GPX import
(HOR-02): pick the file, choose Horse ride in the preview and confirm.
Once a ride is chosen, the preview says in three badges what the import
will and will not do for it: the line stays as the file recorded it, no
riding time is worked out, and nothing checks whether its ways may be
ridden. Each badge gives its reason in a tooltip, reachable by keyboard.
Nothing is stored before the confirmation.

Opened again, the ride's rail carries a Recorded ride card with the same
badges above its measures, and names heights the file did not carry
instead of showing them as zero. Its origin and file name stay in the
tour's provenance, it goes on a day like any tour, and no editable
version is planned from it on foot.

The import itself was already canonical and needed no change; end to end
tests now pin what this card promises: the stored line equals the
recorded points, the ride keeps its origin, distance and heights and has
no riding time, a file without heights keeps them unknown, a failed or
refused import leaves an existing ride and its place untouched, and the
same file twice is skipped.
2026-10-20 15:12:01 +02:00
Maurice 78366a9024 fix(tours): long rides save, the horse route store stays bounded
A ride sends its full line with heights and every horse route section's
own line besides, about fifty bytes a point, so a ride along a few
thousand points of bridleway hit the API's 100kb body ceiling and the
save failed with a 413. Creating and editing a tour are now measured
against TOUR_WRITE_MAX_BYTES (2 MB), like the book and list file
routes; every other route, the tour copy among them, keeps 100kb.

The horse routes kept in memory are now bounded by section points as
well as by count (64 answers, 500k points), so panning a dense map can
no longer grow the server by hundreds of megabytes.

A search that found no connection can be asked again on the view whose
routes are shown, and a ride with a recorded time shows the horseshoe
beside its riding time instead of footprints.
2026-10-20 15:11:25 +02:00
Maurice 0331d9950f feat(tours): plan rides on horseback from signposted horse routes
A tour can now be a ride (HOR-01). The planner offers "Horse ride" as a
tour type with its own horseshoe, and a Horse routes card beside the
waypoints looks, when asked, for the route=horse relations OpenStreetMap
knows in the map view. A route opens in a dialog that shows it on a map in
the sections it is made of, split at forks, gaps and crossings, so nothing
is ever joined across a gap. The rider picks a section and its direction,
sees what the horse=* tags along it say (open, limited, closed, or unknown
where untagged) and whether it is signposted the other way, and takes it as
one undoable step. A section that does not start where the ride ends is
only joined by a straight manual stretch once the rider switched that on.

Nothing of a ride is routed: every leg follows a section or is drawn by
hand, and the waypoint list and the map say which (a section as a solid
line with its route, a manual stretch dashed and marked unchecked). A point
at a section stays where the route has it, a ride works out no riding time,
and distance, heights, day assignment, GPX import and export, the 3D view
and the reversed ride keep working with each leg's origin saved and opened
again. A failed lookup leaves the ride and the routes already found as they
were.

The server reads the routes through the shared Overpass client
(GET /api/trips/:tripId/tours/horse-routes, Tours addon, trip access),
keeps an answer twelve hours per grid-aligned view, and the tour contract
refuses a routed leg, a via ferrata or a riding time on a ride and a horse
route section on any other type. A migration enables the horse tour type.
2026-10-20 15:11:08 +02:00
Maurice dfe11e9c4c fix(tours): give the snowboard a corner glyph of its own
Snowboarding used the snowflake, the same glyph the winter hike label
shows in the same corner badge, so a snowboard track looked like a winter
hike in lists and the day plan. It now draws a board with its bindings.
2026-10-20 15:06:06 +02:00
Maurice fcdb7e48cd fix(tours): start the winter label from winter hiking on every other tour
The winter field remembered the label the switch brings back in its own
state, seeded once on mount. The details section stays mounted while the
planner saves, closes or opens another tour, so a snowshoe hike left its
choice behind and the next unrelated hike switched on as snowshoeing.

The planner now keeps that choice beside the label itself: a label set is
remembered, starting a new tour or discarding resets it to winter hiking,
and opening a tour takes the tour's own label.
2026-10-20 15:06:06 +02:00
Maurice 008aaeb408 fix(tours): say to zoom in when the pistes are on further out than they draw
The pistes overlay has no tiles below zoom 9, so switching it on at a region
or country view left the map empty while the switch looked on, which read as
broken. Both maps now report their zoom with the viewport, on the 256 px tile
scale raster sources count in (a GL map's own zoom is one step lower there),
and the planner hands it to its overlays. While the pistes are on and the map
is further out than their first tiles, the switch stays pressed in the soft
accent, its tooltip and the key's first line say to zoom in, and the screen
reader hears it once. An unknown zoom claims nothing.
2026-10-20 15:06:05 +02:00
Maurice 5ff7e54e09 feat(tours): lay pistes, cross-country trails and lifts over the planner map
SKI-01. The Tours planner's layer switch gains a winter layer: OpenSnowMap's
transparent piste overlay over whichever base map is shown, so a ski area
can be looked at while preparing a winter trip. It is offered with no tour
open (a resort without any route), beside a hike for winter hiking and on an
imported ski track; it needs neither a GPX import nor the ski type, and a
bike tour, a run and a paddle tour do not offer it. It is off until switched
on, and the choice belongs to the map view: switching it changes no point,
no step and no route, and it stays on from no tour to a hike and into 3D,
where the terrain draws it too.

The switch goes by its icon alone, named by its tooltip, so a hike's row
stays short. While the layer shows, a small key button rides beside it in
the same pill, its badge the four grade colours. The key opens above the
map as a popover: the downhill grades in OpenSnowMap's own colours (novice,
easy, intermediate, advanced, expert, freeride), cross-country trails,
winter and snowshoe trails, toboggan runs and lifts, a note that this is
mapped data that says nothing about what is open or groomed, the snow or
avalanches, and a link to the full key at OpenSnowMap. If the tiles stop
coming, the switch turns to the warning tone with its badge and says that
the map and the route still work, and the key stays away.

Only the overlay is used, never OpenSnowMap's relief or base maps. Like
every overlay it loads only the tiles in view: nothing is prefetched or
cached by the Service Worker, as their terms forbid bulk downloads. The
tiles go out with the page's origin as the referer, the attribution credits
OpenSnowMap (CC BY-SA) and the OpenStreetMap contributors, and the layer
stops at zoom 18, beyond which the server answers a picture instead of a
transparent tile. The CSP admits tiles.opensnowmap.org for the GL map,
which reads tiles through fetch.
2026-10-20 15:06:03 +02:00
Maurice 0169be3d79 feat(tours): import a ski track from GPX and keep it as recorded
SKI-02. Ski joins the types a GPX file can be imported as, offered in the
import preview's type field only: TREK routes no ski track, so the planner
never offers it. The preview asks for the discipline on purpose (alpine,
snowboard, cross-country or ski touring, none chosen at first) and, for
cross-country, the technique (classic, skating or unknown); Import waits
until both are named, and the technique stays in sight but dimmed for the
other disciplines. The track is stored exactly as recorded, with its file
name as provenance, its distance and heights from the file and no time:
no ski time is worked out and no hiking estimate stands in for one.

Opened in the planner, a ski track gets a rail card with its discipline on
a tinted tile, a cross-country technique as a badge beside it, the ski time
said as not worked out, and an info dot on what TREK does not say (snow,
avalanches, open runs, and that the 3D gradient colours are no difficulty
or avalanche rating). Change opens a dialog to classify the track again;
only the classification changes, through the offline core, with the
revision it was made at. Planning over the import is not offered for ski.
The existing 3D terrain view, the elevation profile and its focus work on
the track as for any import, with the standard map, topo and satellite and
no via ferratas. Lists, the day plan and the detail show the discipline as
a frosted corner badge on the tile and as a pill with the technique inside;
day assignment, copies and take-overs work as for any tour. The corner
badge is now one component that the winter label shares.

The server keeps tours.ski_discipline and tours.ski_technique (CHECK lists,
NULL on every other type), switches the ski type on without a routing
profile, refuses a ski import without its discipline, a technique outside
cross-country and ski fields on any other type, and takes a planned save's
classification off. PUT /api/trips/:tripId/tours/:placeId/ski classifies a
ski track again (place_edit, 409 on a stale revision, 400 for a tour that
is no ski track) and broadcasts tours:changed. GPX export names the
discipline (alpine_skiing, snowboarding, cross_country_skiing,
backcountry_skiing), and list_tours describes both fields with what a ski
track does not say. Ski is labelled plainly as Ski in every language, so
it no longer reads as ski touring.
2026-10-20 15:06:01 +02:00
Maurice 68907ec811 fix(tours): name the path grades on the run card without dashes
The run card's grade rows and its hardest-grade badge borrowed the hiking
limit's labels, which read 'T1 — Hiking'. The card now has its own labels in
the bike card's style ('T1, hiking') in every language, and the hiking limit
keeps its own wording.
2026-10-20 15:06:00 +02:00
Maurice 51bfb5abd1 feat(tours): plan a trail run on forest tracks and natural paths
RUN-02. Trail running joins Running as a run profile, chosen on purpose in
the planner and in the GPX import. It asks Valhalla's pedestrian costing for
tracks wherever they serve (use_tracks 1) and admits mountain paths up to T2
(max_hiking_difficulty 2), never T3 and above, with ferries only where no
way on land exists. Checked with trace_attributes against the public
instance: above Freiburg Guenterstal up to the Schauinsland the route went
from 82 % roads and 83 % paved (running) to 78 % forest tracks and 81 %
compacted, gravel and dirt; Rathen to the Bastei took 28 % of T2 path that
running leaves out; Heidelberg's old town stayed as it was. Leaning on
footways (walkway_factor) was tried and left out, it moved runs onto paved
footways.

While a run is planned, a card says what it runs on: its surfaces, the
hiking grades of its paths (sac_scale, T1 to T6, as mappers gave them) and
its kinds of way, each as shares with lengths that point at their stretches
on the map. A way without a grade or a surface is its own row, unknown and
never easy, and an info dot says the grades say nothing about whether a way
can be run. The head carries the unpaved share and the hardest graded path
as badges. The line is matched on foot for this, so steps and mountain
paths stay on it; a bike tour's is matched as before. Reading fails, goes
offline or is retried as for a bike tour, and nothing of it changes the run.

Pace and time are RUN-01's: the running time stays the distance at the
runner's pace, with climbs, descents and difficulty left out, whatever the
profile. A profile change plans the route again and keeps the pace; a
failed trail route keeps the last route and holds Save back. The card's
building blocks moved out of the bike card into RouteWaySections, so both
cards share one set of rows, bars and status lines.

The run_profile column already allowed trail (RUN-01), so no migration.
GPX export names a trail run trail_running, list_tours describes the
profile as a routing preference, never a statement that a way can be run.
2026-10-20 15:05:51 +02:00
Maurice 1d9b9bf838 fix(tours): read a run pace the way runners write it, and keep it
A pace typed as "5.30" or "5,45" is now read as 5:30 and 5:45, the way
it is written in much of Europe, instead of quietly becoming 5:18 and
5:27. One digit after the point stays a tenth of a minute ("5,5" is
5:30); anything else is marked as no pace.

The pace field no longer flashes the out-of-bounds error while a pace
is still being typed: the first digit of "12:30" reads as 1:00, so a
pace outside the bounds now reaches the planner only on leaving the
field or pressing Enter, like text that is no pace already did.

The pace unit is translated ("мин/км", "分/km" and so on) in the field,
the sum the running time is and the pace pills, so what is shown
matches what assistive tech reads out.

A run planned over in place again keeps the pace it holds, so the
runner need not type it again before Save.
2026-10-20 15:05:45 +02:00
Maurice c83eb42730 feat(tours): plan a run at your own pace
RUN-01. Run joins the tour types in the planner, with Running as its first
profile, chosen on purpose like a bike tour's. A run is routed on foot for
runnable, mostly paved ways: tracks and ferries only where there is no other
way (use_tracks 0, use_ferry 0, measured against the public Valhalla), no
hiking limit and no via ferrata. The runner types a pace in min/km; the
running time is the distance at that pace and nothing else, shown under the
field as badges (8 km x 6:00 /km = 48 min). TREK suggests no pace: without
one the time stays unknown and Save waits, text that is no pace or a pace
outside 2:00 to 20:00 is marked. The info dot, the overview and the head say
climbs, descents and difficulty are not in the time, and the time reads as
planned everywhere, so it never passes for a recorded one; the heights are
shown as for any tour on land.

Type, profile, pace and route come back on opening, survive a draft reload,
an undo of the type, a copy and a take-over; switching to another type drops
profile and pace. Day assignment, the 3D terrain view (with topo and
satellite, no ferratas) and a routing failure keeping the last route work as
for every tour. A GPX file can be imported as a run with its profile and no
pace, and planned over as a run. Runs export to GPX as running and get their
own places along the way (water, toilets, parks, viewpoints).

The server keeps tours.run_profile (CHECK list, already holding trail for
RUN-02, since SQLite cannot widen a CHECK in place) and
tours.run_pace_seconds_per_km (120 to 1200), switches the run type on,
refuses a run without a profile and run fields on any other type, and
list_tours names both with what the planned time does not include.
2026-10-20 15:05:38 +02:00
Maurice cd407cf80c feat(tours): a hike can be marked as a winter hike or a snowshoe hike
HIK-01. The details of the hiking planner gain an optional Winter activity
switch, off by default, so the normal hiking flow asks for nothing more.
Switched on, the hike is a winter hike or a snowshoe hike; the choice stays
dimmed and out of reach while the switch is off. The label is saved with the
tour, comes back when it is opened, survives a draft reload, a copy and a
take-over, and switching it off takes only the label away: the route, its
grade and the days stay as they were.

The label is a label only. Routing, the walking time and the 3D terrain view
work as for any hike, and the info dot and the head pill's tooltip say that
nothing was checked for snow, avalanches or whether a path can be walked in
winter. The saved label shows as a frosted snowflake on the corner of the
tour's tile in the tour list and the day plan, and as a pill beside the type
in the tour's detail.

The server keeps it in tours.winter_activity (CHECK list, NULL for every tour
saved before and for every other type), refuses it on a bike tour, a city walk
and a paddle tour, and keeps the stored label when an older client saves a
hike without the field. A snowshoe hike exports to GPX as snowshoeing.
list_tours names the field, with the same words on what it does not say.
2026-10-20 15:05:15 +02:00
Maurice 71fe69c1f1 feat(tours): mark the best known places along a city walk
A search for sights along a tour now also asks the places index for the best
known places near the line, ranked by how often their Wikipedia articles are
read (QRank). A row the index already found is marked as a highlight; a
famous sight no kind names, like a square, is added as a sight of its own.
Stations, hotels and restaurants rank a row they match but are never added.

Highlights are listed first with a star in the list and a badge in the map
card. A place counts as a highlight from 40000 views a year, or the best
three from 5000 where fewer reach that. An index without ranks, or one older
than the endpoint, leaves the search as it was.
2026-10-20 12:16:11 +02:00
Maurice 54baf85a2a fix(tours): a city walk keeps to the bridges and avoids ferries
The walk was routed with the pedestrian router's default, which takes
ferries, so a walk through Venice went along the vaporetto lines across
the canals instead of over the bridges. A walk now asks for no ferries
(use_ferry 0), as a hike that avoids them does; where no way on land
exists the router still takes the ferry.
2026-10-20 11:32:03 +02:00
Maurice 3882feec5c fix(tours): the last review findings of the city walks
A place closing at midnight is no longer read as open past it: the hours
run on only where the next date opens at midnight (any window of the day
counts, as written order is kept), and an impossible date such as
2026-02-31 reads as unknown instead of rolling into March. The hours
source names the trip's places rather than "your own" entries, in all
27 locales, as the pill beside it does.

The day a walk's arrivals are checked for shows its date on the pill,
not only "Day N". The 12 hour clock is tested for a window to midnight.

The along-the-tour term table is built once again, now that the one test
that stubbed the places client without its real exports spreads them.
New test seeding goes through the ORM factories.
2026-10-20 10:37:29 +02:00
Maurice 6477bf5e39 test(admin): fail the addon load with a server error, not a dropped connection
A network error starts the client's connectivity probe, and whether the
app then counted itself offline, where it stays quiet on purpose, raced
the error toast on a loaded runner (FE-MOB-AADD-004 failed in CI twice).
A 500 fails the load the same way and toasts every time.
2026-10-20 10:12:36 +02:00
Maurice 971133a1eb test(tours): read what taking a place in did from the hook test, not from a planner whose points stay put
The card test's planner is a stub whose waypoints never take the new
point, so the list's outcome line, which waits for the point to be in
the route, cannot show there. FE-TOUR-POI-006 covers it with a planner
that does.
2026-10-20 10:01:08 +02:00
Maurice 0344db6d0c test(shared): count the tour keys without the four hint lines taken out
The stay-unknown pill, the along-the-tour intro, its undo hint and the own
theme line left the tours domain in the previous commit.
2026-10-20 09:49:27 +02:00
Maurice 5d265c98de feat(tours): places along the tour picked on the map, stops that keep their kind, and calmer station rows
Along the tour (PLN-02, PLN-03): the list shows one line per place (its
icon, name and distance from the route) and scrolls past ten. A place
picked in the list or on the map opens its card over the map, where the
other cards of the planner sit, with what the source says and the way to
take it in as a stop. What taking it in did is one line in the list. The
intro, the theme line and the out-of-date note are gone: the themes say
what they look for on hover, and the search button says when to search
again.

A stop taken in from there keeps the kind of place it was found as
(migration 045400, poi_category, read back only where the contract knows
the kind) and its number carries that kind's icon at the corner, as a
via ferrata's end does.

City walks (CW-02, CW-03, CW-10): a stay nobody entered says nothing.
While no stay is entered at all the walk is planned as its walking time,
with no "unknown" and no "at least". A must-see rings its number, an
optional stop has a dashed ring, a protected one a shield at the corner;
only the stay stays under the name, below the coordinates.
2026-10-20 09:47:24 +02:00
Maurice 554d6f1b9f feat(tours): a city walk prepared from a briefing over MCP, and the station fields an assistant reads spelled out (CW-10, CW-11)
An assistant connected over MCP can now prepare a city walk from what the
traveller tells it. propose_city_walk takes a start (and an end), a time
frame on a day of the trip, the themes of CW-05, the stations the
traveller wishes for, the places of the trip it may use and fixed
appointments with their times, and saves nothing. Places for the themes
come from the search along a tour, at most eight, each with the theme,
category and source that brought it; nothing is made up. The stations are
ordered on estimates over the straight distance, and a place of a theme or
of the trip goes in only where it is open on arrival, keeps the frame and
the appointments, and leaves every station already on the walk open when
the walk gets there. The walk in that order is then planned on foot by the
router the road trip uses (the traveller's own routing server where they
set one), and its legs give the walking distance and time from one station
to the next. Where the router cannot be reached, or a station lies more
than 300 m off every way (the planner's limit, now shared with the
server), the times are estimates over the straight distance with an
allowance for streets at 4.5 km/h, and the answer says so and why. The
answer gives the arrival and leave times, the stay where the traveller
gave it and unknown otherwise, which makes every later arrival the
earliest one, and the opening hours checked against the arrival on that
day with the helpers the planner uses. Walking and stays are counted
apart, the total is complete only when every stay is known, and the
minutes the frame leaves for the unknown stays are named. Free time before
an appointment or at the end, wishes and appointments that cannot be kept
as asked, and places left out with their reason are listed; a tour of the
trip offered as a station is named as a tour. A wish or an appointment is
never dropped; an appointment is a must and protected. A place's default
duration is not read as a stay: every place carries one, so it says
nothing.

Once the traveller confirms, create_city_walk saves the walk through the
same creation the planner's save uses: one write, a key for every station,
the links to the trip's places checked, the trip told, with place editing
permission on the trip. Its way on foot is planned by the same router and
saved with the walk, together with its distance and walking time. Where
the router gives no way, the walk is saved with its stations only and the
answer says why: no line, no distance and no walking time. The tours list
says so (route_pending), the tour detail says in one line that the way is
planned in the planner, and opening the walk in the planner plans its way
at once and offers to save it, with a note once it is planned that it was
just planned. Such a walk can be saved as a new tour and taken over into
another trip, starting at its first stop, and is never offered as a place
to stay. A tour without a line needs two points of its own.

get_tour_plan now spells out every field of a city walk's stations: key,
name, stay (null when not known), importance (null for normal), protection
(false when not set) and opening hours (null when none are known);
list_tours names the station count and whether a walk waits for its way,
get_city_walk_progress gives each station's key and opening hours, and
take_over_tour says a walk keeps its stations with their protection. No
tool changes a walk's stations; deleting a walk's place takes its stations
along, protected ones included, and both delete tools now ask the
assistant to name those and ask the traveller first. REST answers the
stations as before.

A tour create refused for a type that is switched off, a description that
is too long or planned times out of range is a domain refusal now, so an
assistant gets the same text REST sends; REST still answers it as an error
at 400.
2026-10-20 07:09:57 +00:00
Maurice f79b2020d6 test(admin): wait for the addon list's empty state after a failed load
The empty state shows once loading has ended, which can come a render after
the toast; the test read it in the same tick and failed now and then.
2026-10-20 07:09:57 +00:00
Maurice 9f0caf8539 feat(tours): public transit between two city walks in the day plan, from the first walk's end at the time it ends (CW-08)
"Public transit" on the connector between two city walks in the day plan,
on the desktop and on the phone, now opens the existing search from where
the first walk really ends to where the next one starts, at the
coordinates the day's route draws, named after the first walk's last stop
and the next walk's first stop. A loop ends at its first stop again. A stop
without a name takes the name of the trip place it stands for, and a walk
whose stops cannot be read on this device names that end "End of" or
"Start of" the walk. The search opens on the walks' day and departs when
the first walk ends: its start that day, the time on the day's entry
before the place's own, plus its planned total, a total entered by hand
before walking and stays. The assignment's own end time is not used.

Above the search a note says when the first walk ends, "at the earliest"
where a stay or the walking time is not known yet, and when the next walk
starts. Without a start or a total no time is made up: the time field
stays empty and Search waits until one is chosen. A walk that only ends
after midnight says so, and the time field stays empty as well. On any other day, or for
any other leg, the search opens as before, with 09:00 as its default. A
connection that arrives after the next walk starts says so in its card,
before its legs are opened and before it is taken. Nothing is saved until
"Add to day", which keeps the connection as its own journey in the plan as
before; both walks stay two walks on foot and nothing is moved.

A journey saved before a walk that now arrives after the walk starts says
so on its row in the plan, on the desktop and the phone, while the plan is
looked at. Nothing is rewritten. The desktop no longer draws a connector of
nothing between a stop and a booking at the same spot, as the phone already
did, so a journey saved at the walks' ends adds no walk on either side. A
leg leaving a loop tour no longer departs at the tour's start time.

The leg is told by the walk it leaves, so two walks of a day that end at
one spot, such as two loops from the hotel, each search from their own end
and time, and a stop between two walks keeps the leg an ordinary one. Only
the leg asked for last opens the search, and none opens once the plan is
left while the walks' stops are still being read.

Both views run on one shared hook, the walk's stops are read through one
helper the stop list uses as well, and the walk's start on a day is worked
out in one place for the times shown in the detail and here.
2026-10-20 07:09:57 +00:00
Maurice 7470a2c6d4 fix(tours): a short link whose name does not resolve just now, or that Google does not know, is said as such in the walk import (CW-06, CW-07)
When the name of maps.app.goo.gl or goo.gl could not be looked up for a
moment, the walk import said the short link leads somewhere other than
Google Maps. It now says the link could not be followed just now and answers
502, as for any other failure on the way; a name that leads into this
network is still refused.

A short link Google answers with 404 or 410 is one it does not know, and
asking again changes nothing. The dialog now says so in the reader's
language and asks to share the route from Google Maps again, with a 400 and
the reason short_link_unknown, instead of suggesting to try again later. A
busy or blocking answer from Google stays a failure that may pass.

The MCP tool preview_google_maps_walk is marked open world, since it follows
a short link to Google on every call, and the English text REST and MCP send
for each reason is held to the English text the planner shows.
2026-10-20 07:09:57 +00:00
Maurice 4ca5728072 feat(tours): a city walk from a Google Maps walking route, its short link followed only to Google Maps (CW-06, CW-07)
"Add tour" in the planner now offers "Import walk from Google Maps". The
dialog takes the link of a route shared in Google Maps, either a
maps.app.goo.gl short link or the long google.com/maps/dir link, or the
share text of the Google Maps app with the one link in it. It shows the
stops in their order with their names, marks a stop the link gives no
position as such, says that the route comes from Google Maps with a link to
open it there, and that TREK plans the way between the stops itself and
does not take the line drawn in Google Maps over. Nothing is written until
"Load as walk": that opens a new, unsaved city walk in the planner with the
positioned stops and their names, routed on foot by TREK, and warns first
when it replaces unsaved work. A stop without a position is left out and
never looked up or guessed; with fewer than two positions nothing can be
loaded. While the walk is not saved, a calm notice in the planner says
where it came from, also after a reload of the draft. Cancelling or a
failure leaves every tour as it was.

Only a route the link marks unambiguously as on foot is read. A car, bike
or transit route, a link that does not say, or one that says two things is
refused with a reason the dialog gives in the reader's language. Each stop
takes its position from its own block of the link's data, so a line
dragged through extra points or a stop given as coordinates no longer
leaves the stops without positions or shifts them. The road trip and
places imports read links as before.

A short link is followed by a resolver of its own: every hop is checked
against the Google Maps short link shapes and the strict SSRF guard before
it is looked up or asked, so ALLOW_INTERNAL_NETWORK and
ALLOW_LINK_LOCAL_IPS do not open it. Each request goes to the vetted
addresses only, follows nothing by itself, reads no body and carries no
credentials; the chain has one deadline including name lookups, at most
three hops and a cap on the Location header, and the long link it ends on
must be Google Maps and is never fetched. The preview is POST
/api/trips/:tripId/tours/import/google-maps/preview for members who may
edit places, with a 502 when Google could not be reached, and the MCP tool
preview_google_maps_walk reads the same.
2026-10-20 07:09:57 +00:00
Maurice 2203ff3a2e feat(tours): a city walk's arrivals and opening hours for the day it is on, at each stop in the planner and the detail (CW-02, CW-04)
A city walk now shows, at each stop, when the walk gets there, until when
the stay lasts, and what the stop's opening hours say about that visit. The
same lines appear under each stop row in the planner and under each stop in
the walk's detail, in the user's 12 or 24 hour format. A conflict names the
stop, the arrival, the end of the stay and the hours it was checked against,
for instance "Lenbachhaus closes at 18:00, during the stay from 17:20 to
18:20 (open 10:00 to 18:00)", in the warning colour. Nothing is changed by
these hints: no stop, order or route moves.

The times belong to one day the walk is on. Opened from a day of the plan,
the detail keeps that day, and checking stops off stays bound to it.
Opened otherwise, and in the planner, a small pill above the stops names
the day and its start, and the walk's other days can be picked from it; it
starts on the first day with a start time. The pick is only what is looked
at and is never saved, and another day never takes over the times quietly.
Without a day, a date or a start time the line says what is missing and no
arrival is shown. TREK keeps no time zone on a trip, so start, arrivals and
opening hours are all read as the local time of the plan, and an arrival
after midnight is said to be on the next day without being checked.

Opening hours come from the trip place a stop stands for, as the travellers
entered them, and otherwise from OpenStreetMap; a pill at each stop names
the source, and a notice says the hours are a hint to check. A stop without
hours stays unknown and is never shown as open, hours that are not in a
plain form are shown as written, and an arrival that is only a lower bound,
because a stay before it is not entered yet, is not judged unless no later
arrival could escape the conflict. A trip place that a stop stands for also
gives the stop its name before "Stop N".

The arrival at a stop is found where the route passes it in the walk's
order, so the end of a loop and a stop passed on the way out and back get
the right time. The rules live once in the shared package, for the server
to give the same answers. The walk's detail reads its stops again after a
save, its own or a teammate's.
2026-10-20 07:09:57 +00:00
Maurice 0d7e3ceb3d fix(maps): read the index terms when a row is judged, not when the module loads (PLN-02)
A suite that stubs the places index client without its term table could no
longer load the maps service, because the list of every term was built when
the module was first imported. It is read where a row is judged now.
2026-10-20 07:09:57 +00:00
Maurice 93a508a0e6 fix(tours): count a city walk's visits event in the realtime inventory (CW-03)
The registry of realtime events gained tours:visits when a city walk's
stops could be checked off for every member of the trip, but the test that
pins the inventory still counted 76 trip events and 109 in all. It now
counts 77 and 110 and says which event the 77th is.
2026-10-20 07:09:57 +00:00
Maurice 0b92dcc86b feat(tours): show a city walk's progress on the phone's day row, and check its stops off offline (CW-03)
The day row on the phone now shows how many stops of a city walk were
visited on that day, "2 of 5 visited", like the desktop row does. Both rows
read it from one place, and a tap on the phone names it in full without
opening the row.

A walk's stops can be checked off, skipped and taken back without a
connection. The mark shows at once, waits in the offline queue and goes out
when the connection is back, in the order the marks were made and behind any
older mark of the same day that still waits. The day plan, the walk's detail
and a reload read the visits kept on this device with the waiting marks on
top, so a mark made offline stays on screen until the server has it. A mark
the server answers with "not found", because the stop, the day or the walk
was removed meanwhile, counts as done and holds back no later mark of that
day. Any other refusal, the Tours addon turned off among them, keeps the mark
as a failed change to try again from the offline settings. The visits are
read again when the connection comes and goes, and once more after the
waiting marks went out, so a mark the server dropped or refused leaves the
day plan. A trip's visits that answer after another trip was opened no longer
show there.

The offline sync keeps each trip's walk visits and reads the stops of a walk
this device has never opened, so it can be checked off offline too. A walk
whose stops this device could not read lists no stand-in stops and no count,
and says to open it again while online. A walk put on a day while offline
says its stops can be checked off once it has synced, instead of offering
buttons that cannot work yet. A mark that fails says to open the walk again
and try once more, in every language.

Marks carry no version, so the last one to reach the server wins, as with
the other changes sent after being offline. The offline guide says so.
2026-10-20 07:09:57 +00:00
Maurice c836ee3988 fix(tours): a city walk saved offline shows its stays and total at once, and the planner plans the total its overview shows (CW-02)
A city walk created or changed while offline now carries its stops, the
minutes its stays are entered with and how many have none yet, worked out
from its points the way the server will answer them. The day row, the tour
detail and the summary show the walking time with the stays as soon as the
walk is saved, with "at least" where a stay is missing, and a stay changed
while the write waits shows its new total rather than the one of the last
answer. The server and the client count the stops in one shared place.

The planner plans a walk whose walking time is not known yet with at least
the stays entered, as its overview says, so the planned field and the check
against the longest plan read the same total.

In the tour detail a longer time such as an "at least" total wraps on a
phone instead of being cut, and the boxes of a row keep one height. A total
entered by hand falls back to its short name where its column is too narrow.
2026-10-20 07:09:57 +00:00
Maurice 188953158a fix(tours): a city walk's walking time, stays and total read apart and alike everywhere, and a stay plans no route again (CW-02)
A stay, a name, an importance or a protection set in a stop's dialog no
longer plans a city walk's route again: the walk stays ready to save, its
time overview follows at once, and one undo takes the change back without
routing either. A stop moved or added plans the route again as before. A
walk saves no breaks, so a cafe never counts as a stay and as a break at
once, and a break left from another type neither counts nor holds its save
up.

The planner and the tour detail show a walk's walking time, its stays and
their total apart and in the same words: 45 minutes of walking, a museum of
90 minutes and a cafe of 30 minutes make 2 h 45 min. A stay not entered yet
makes the stays and the total at least what is known, stays nobody entered
read not known yet, and stays entered as 0 read 0 min. A total entered by
hand keeps its own label, and the computed total shows beside it. The head
of the tour detail, the day row on the desktop and the phone, the tour
summary and the library of other trips show that same total, with a ≥ where
a stay is missing. Their time on the move is called walking, and the summary
says a walk is planned with its stays rather than breaks.

The tour list and the library carry the minutes a walk's stops are entered
with and how many have no stay yet, counted from the walk's points, so the
way back of a loop is no stop and its start's stay counts once. A loop run
the other way keeps its start as the stop there, with its key, name and
stay.

The planner's waypoint list keeps the rows of a leg in a file of their own,
and the tour detail its name heading in a part of its own, so both stay
within their size limits.
2026-10-20 07:09:57 +00:00
Maurice 20f8266439 fix(tours): keep a city walk's visits through a save from the planner, and count a loop's start once (CW-02, CW-03, CW-09)
A walk saved from the planner kept its stops without the keys the server
gave them, so the next save from the same open planner handed every stop a
new key and dropped the visits checked off in the day plan meanwhile. The
planner now takes over the keys a save answers, point by point, without
planning the route again, so a stop keeps its key and its visits from one
save to the next.

The edits of a stop's dialog now reach the planner: a changed name, stay,
importance or protection counted as no change and was dropped. It is a step
of its own that can be undone, Save stays available, and the route is not
planned again for it.

A save that takes stops out of a walk, or makes it another type, tells every
client of the trip which visits are left on each day the walk is on, the
client that saved included, so the progress in the day row no longer counts
a stop the walk has lost.

The way back of a loop is the start again and no stop of its own. Closing a
loop gives it only the start's spot and the place it stands for, and the
planner's stays, the stops of the tour detail, the stop count of the tour
list, the visits and the progress an assistant reads leave it out, so a loop
through three stops counts three, the start's stay counts once and the way
back cannot be checked off on its own. A loop saved before keeps working.
The rule lives in one helper the server and the client share, and the tour
list takes its stop count from the walk's points through it.

The tests that seed a walk for the assistant's progress go through the ORM
factories.
2026-10-20 07:09:57 +00:00
Maurice e5f01e570a fix(tours): check a city walk's stops off and save a stop from its dialog, with the walk under test (CW-01 to CW-05, CW-09)
Marking a stop of a city walk visited or skipped answered with a server
error every time: the write that keeps a changed status stamped its time as
a function call, which neither SQLite nor Postgres takes. It now uses the
clock the database itself spells, so a stop checked off, skipped or taken
back stays that way and the day plan of everyone on the trip follows.

The Save button of a stop's dialog in the planner did nothing, because it
sat outside the dialog's form. It saves the name, stay, importance and
protection again. A walk saved with two points that carry one key, as a
loop back to the start does, keeps that key on the first point and gives
the other one of its own, so checking one off never marks both. A walk put
on one day twice is read for an assistant from its first assignment.

The server tests cover the visits from the repository to the routes: per
day, through a moved and a removed assignment, a copy of the walk and a
copied trip without visits, a removed stop taking its visits along, and
the refusals for another trip, a stop the walk does not have, a member who
may not edit days and an unknown key or body. The MCP progress of a walk,
the station fields and counts of the tour list, and the contracts in
shared are tested too; the existing tests expect the keys every saved
point now carries, the station count of a list row and the new entity.
The client tests cover the stop dialog and its pills, the walk themes and
categories of the search along a tour, the stop menu of the waypoint list,
the walk's timing, routing and type choice, and answer the trip planner's
read of the visits in the tests instead of leaving it unanswered.
2026-10-20 07:09:56 +00:00
Maurice 2a8dbf91a8 fix(tours): keep the city walk helpers only their own files use inside them (CW-01, CW-03, CW-04)
The filter for a walk's stations in the planner, the opening windows of a
walk's schedule and the station shape of a walk's progress were exported,
although nothing outside their own file reads them, so the check for unused
exports counted three more than it allows. They are local to their files now;
a city walk plans, checks off and times its stops as before.
2026-10-20 07:09:56 +00:00
Maurice ec9e1a7533 fix(tours): name the Overpass kinds of a search along a tour at once while every mirror is down (PLN-02, CW-05)
While the public Overpass mirrors do not answer, every search along a hike
with viewpoints, huts or drinking water waited out the whole twelve second
deadline only to name those kinds as not searchable. Once every mirror has
failed the search along a line, and no other search along a line was
answered in the meantime, the next searches for three minutes leave Overpass
alone and name its kinds at once, next to the places the index found; a later
stretch of a long line that was already being searched does not wait either.
A stretch answered shortly before keeps its places from the cache during the
pause. After the three minutes the next search asks again for real, and any
answer ends the pause. The log holds one line per pause with the time it
ends, next to the line of the search that failed. The explore buttons of the
map and the other Overpass lookups are unchanged.
2026-10-20 07:09:56 +00:00
Maurice 3c823e07f4 fix(tours): search along a tour through the places index along the line, and name what could not be searched (PLN-02, CW-05)
The search along a tour found nothing. It asked the places index for all
kinds of a stretch at once, above the 100 rows and 200 characters of terms
the index takes, so the index refused and every stretch went to the public
Overpass mirrors, which do not answer from this server. Now the index is
asked in small circles strung along the line, one request per kind and
circle within its limits, and an empty circle counts as an answer, so a
city walk lists its sights, museums, parks, cafes, restaurants and markets
in well under a second. The kinds the index does not know (viewpoints,
huts, drinking water, toilets and the like) are asked of Overpass along the
whole line at the same time, one query per stretch of up to 50 km, three at
a time, each with a short deadline of its own; a line past 400 km is
searched along its start and says so. When the index answers no circle at
all, its kinds go to Overpass beside the others instead of after them. A
kind no source could be searched for is named in a calm notice above the
list instead of reporting nothing found, and the reason is written to the
log. Unnamed taps, toilets, shelters, picnic spots, viewpoints, summits,
slipways and repair stands stay in the list and on the map under the name
of their kind; an unnamed garden, cafe or memorial is left out. The places
nearest the line are kept when there are more than sixty, listed from the
start, with the opening hours the source has.

A walk's parks are parks and gardens only, and its museums are museums
and galleries only, no longer the beaches, summits, theatres and cinemas of
the map's explore buttons; a water park the index files under parks is not
one of them. The index also answers markets and summits. The explore
buttons keep their single request, and a search of several kinds that
would exceed the index limits is asked one kind at a time instead of being
refused. The MCP tool search_tour_pois and its wiki row describe the walk
kinds and the new unavailable list, and the OVERPASS_TIMEOUT_MS row says
the search along a tour keeps its own shorter deadline.
2026-10-20 07:09:56 +00:00
Maurice 9ed944c1e6 fix(tours): city walk gates: entity collection, formatting, a walk's name hint and the tests' visits repository
The day assignments entity carries the station visits that hang off it, as
the generator writes it. A city walk gets its own placeholder for an untitled
name, the tour service tests hand in the visits repository the service now
takes, and the files the walk touched are formatted. The theme icons of the
place search overlap by their start margin, so they stack the same way in
both reading directions.
2026-10-20 07:09:56 +00:00
Maurice 2955c61c86 feat(tours): a city walk's arrivals against its stops' opening hours, and a compact place search (CW-04, CW-05)
Opened from a day with a time, each stop shows its estimated arrival: the
start, the walking time shared out along the route, and the stays before
it, at the earliest while a stay is unknown. A stop whose opening hours say
it is closed then, or closes before the stay ends, says so with the times;
hours that cannot be read are shown as they are, and a stop without hours is
unknown, never open. The themes are a two by two grid and the categories one
line with a list to tick, so the search no longer fills the rail. The button
that discards a draft for a new tour says so in two words.
2026-10-20 07:09:56 +00:00
Maurice c97105ce61 feat(tours): fill a city walk from a theme: culture, parks, food or a bit of everything (CW-05)
The search along the route offers a walk's own categories (sights, museums,
parks, views, coffee shops, places to eat, markets) and four themes that set
them at once. A found place says which theme brought it, its source and how
far it lies from the line, straight across; taken in as a stop, it keeps its
name and opening hours. Nothing is taken in without the existing confirm.
2026-10-20 07:09:56 +00:00
Maurice 290961cced feat(tours): check a city walk's stops off for the day it is on (CW-03)
The walk's detail opened from a day lists its stops in order, each with its
stay, importance and protection, and marks one visited or skipped for that
day, or takes it back. The day row shows how many were visited. Marks reach
the other travellers at once and change no route, place or order; a copy or
another day starts with none.
2026-10-20 07:09:56 +00:00
Maurice 4c594876ef feat(tours): plan a city walk with its stops, stays, importance and protection (CW-01, CW-02, CW-10)
City walk is a type of the planner: routed on foot without a hiking limit, on
the standard map. Each stop is edited in its own dialog, its name, its stay
(empty is not known, 0 a point passed through), its importance and whether a
proposed change must leave it; none of it plans the route again. The walk's
time is walking plus the stays, at least that much while a stay is unknown,
with no breaks on top.
2026-10-20 07:09:56 +00:00
Maurice d5d2d6b25e feat(tours): city walks in the contract and on the server (CW-01, CW-02, CW-03, CW-09, CW-10)
A tour can be a city walk. Its points are stations that keep a key across
saves, a name, a stay (0 for a point passed through, empty when unknown), an
importance and a protection; the visits of a walk's stations are kept per day
assignment, so another day or a copy starts with nothing visited. A station
that is gone takes its visits with it. An assistant reads how far a walk got
on a day, what is open and which times are known.
2026-10-20 07:09:56 +00:00
1456 changed files with 139529 additions and 4576 deletions
+3
View File
@@ -26,5 +26,8 @@
*.eot binary *.eot binary
*.pdf binary *.pdf binary
*.zip binary *.zip binary
# Ski area graphs (SGR1) as the Places host writes them, byte for byte
*.sgr binary
*.gz binary
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests # A GPX fixture whose byte-order mark and CRLF line endings are what it tests
server/tests/fixtures/gpx/bom-crlf.gpx -text server/tests/fixtures/gpx/bom-crlf.gpx -text
+4 -1
View File
@@ -261,8 +261,11 @@ jobs:
# constructor parameter does not break its hand-wired call sites at # constructor parameter does not break its hand-wired call sites at
# compile time: the argument arrives as undefined and throws at # compile time: the argument arrives as undefined and throws at
# runtime, if anything reads it at all. This is the step that # runtime, if anything reads it at all. This is the step that
# catches it. # catches it. src and tests together are past 570,000 lines and need
# about 4.3 GB, over Node's default heap.
run: cd server && npm run typecheck:tests run: cd server && npm run typecheck:tests
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Typecheck entity generator - name: Typecheck entity generator
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts; # tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
+2 -2
View File
@@ -102,8 +102,8 @@ env:
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance. # lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
# Defaults to 25000. # Defaults to 25000.
# TREK_PLACES_ENABLED: "false" # TREK_PLACES_ENABLED: "false"
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads) # Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads,
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that # and the OpenStreetMap ways and horse routes Tours reads) asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send # host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided # nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on. # here. Defaults to on.
+4 -4
View File
@@ -8,15 +8,15 @@
"trek/no-swallowed-catch": 133, "trek/no-swallowed-catch": 133,
"max-params": 35, "max-params": 35,
"trek/store-without-selector": 69, "trek/store-without-selector": 69,
"react-hooks/refs": 559, "react-hooks/refs": 557,
"react-hooks/set-state-in-effect": 197, "react-hooks/set-state-in-effect": 196,
"@typescript-eslint/no-floating-promises": 167, "@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 121, "react-hooks/exhaustive-deps": 121,
"@typescript-eslint/no-unused-vars": 129, "@typescript-eslint/no-unused-vars": 127,
"react-hooks/immutability": 28, "react-hooks/immutability": 28,
"max-lines-per-function": 133, "max-lines-per-function": 133,
"react-hooks/static-components": 78, "react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 15, "react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7, "no-useless-assignment": 7,
"no-empty": 19, "no-empty": 19,
"trek/no-window-globals": 41, "trek/no-window-globals": 41,
+40
View File
@@ -223,6 +223,15 @@
"desktop": ["src/pages/CollectionsPage.tsx"], "desktop": ["src/pages/CollectionsPage.tsx"],
"mobile": ["src/mobile/screens/collections/MCollections.tsx"] "mobile": ["src/mobile/screens/collections/MCollections.tsx"]
}, },
{
"feature": "Collection tours (LIB-03): useCollectionTours, useCollectionTourTakeOver",
"hooks": [
"src/components/Tours/collection/useCollectionTours.ts",
"src/components/Tours/collection/useCollectionTourTakeOver.ts"
],
"desktop": ["src/components/Tours/collection/CollectionToursSection.tsx"],
"mobile": ["src/mobile/screens/collections/MCollTours.tsx"]
},
{ {
"feature": "Copy collection places to a trip", "feature": "Copy collection places to a trip",
"hooks": ["src/components/Collections/useTripCopyPicker.ts"], "hooks": ["src/components/Collections/useTripCopyPicker.ts"],
@@ -390,6 +399,18 @@
"src/mobile/screens/journey/mobileJourneyMeta.ts" "src/mobile/screens/journey/mobileJourneyMeta.ts"
] ]
}, },
{
"feature": "Journey day take-over: useJourneyDayImport",
"hooks": ["src/components/Journey/useJourneyDayImport.ts"],
"desktop": ["src/components/Journey/JourneyDayImportDialog.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyDayImportSheet.tsx"]
},
{
"feature": "Journey day recording: useJourneyRecordingDay",
"hooks": ["src/components/Journey/useJourneyRecordingDay.ts"],
"desktop": ["src/components/Journey/JourneyRecordingDialog.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyRecordingSheet.tsx"]
},
{ {
"feature": "Journey detail page: useJourneyDetail", "feature": "Journey detail page: useJourneyDetail",
"hooks": ["src/pages/journeyDetail/useJourneyDetail.ts"], "hooks": ["src/pages/journeyDetail/useJourneyDetail.ts"],
@@ -571,6 +592,24 @@
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"], "desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"] "mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
}, },
{
"feature": "Public transit for one leg, between two city walks from the first one's end, and a journey late for the next walk",
"hooks": ["src/components/Planner/useTransitLegPlanner.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
},
{
"feature": "A tour's start and end in the day plan, its end worked out from its time, and an end that runs into the next timed item",
"hooks": ["src/components/Tours/tourDayTime.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
},
{
"feature": "A multi-day tour put on a day: the whole tour there, or a stage a day from that day on",
"hooks": ["src/components/Tours/stages/stageSpread.ts"],
"desktop": ["src/components/Tours/stages/TourDayChoiceDialog.tsx", "src/components/Tours/stages/TourDayAddButton.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MTourDayChoiceSheet.tsx"]
},
{ {
"feature": "Reservation form incl. hotel accommodation creation", "feature": "Reservation form incl. hotel accommodation creation",
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"], "hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
@@ -874,6 +913,7 @@
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).", "src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).", "src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.", "src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.", "src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.", "src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.", "src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
+5 -5
View File
@@ -7,14 +7,14 @@
"src/components/Budget/CostsPanel.tsx": 1816, "src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1401, "src/components/Map/MapView.tsx": 1401,
"src/components/Map/MapViewGL.test.tsx": 2920, "src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2296, "src/components/Map/MapViewGL.tsx": 2293,
"src/components/Map/RouteCalculator.ts": 1195, "src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082, "src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5378, "src/components/Planner/DayPlanSidebar.test.tsx": 5378,
"src/components/Planner/DayPlanSidebar.tsx": 2971, "src/components/Planner/DayPlanSidebar.tsx": 2963,
"src/components/Planner/PlaceFormModal.test.tsx": 2864, "src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1250, "src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/PlaceInspector.tsx": 1304, "src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738, "src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/autoLayout.ts": 1691, "src/components/Studio/autoLayout.ts": 1691,
@@ -24,7 +24,7 @@
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507, "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030, "src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348, "src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1142, "src/pages/TripPlannerPage.tsx": 1140,
"src/pages/atlas/useAtlas.ts": 1287, "src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309, "src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
+54
View File
@@ -0,0 +1,54 @@
// FE-API-COLLECTION-TOURS-001 to FE-API-COLLECTION-TOURS-002
//
// LIB-03: every method pins its verb, its path and its body, and hands back what
// the server answered.
import { http, HttpResponse, type JsonBodyType } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { collectionToursApi } from './collectionTours';
const BASE = '/api/addons/collections/3/tours';
let calls: Array<{ method: string; path: string; body: unknown }> = [];
beforeEach(() => {
calls = [];
});
function record(data: JsonBodyType | null) {
return async ({ request }: { request: Request }) => {
const text = await request.text();
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
return data === null ? new HttpResponse(null, { status: 200 }) : HttpResponse.json(data);
};
}
describe('collectionToursApi', () => {
it('FE-API-COLLECTION-TOURS-001: lists, reads, keeps and removes the tours of a collection', async () => {
server.use(
http.get(BASE, record({ tours: [] })),
http.get(`${BASE}/4`, record({ tour: { id: 4 }, line: [], points: [], trips: [] })),
http.post(BASE, record({ tour: { id: 5 } })),
http.delete(`${BASE}/4`, record({ success: true }))
);
expect(await collectionToursApi.list(3)).toEqual({ tours: [] });
expect((await collectionToursApi.detail(3, 4)).tour).toEqual({ id: 4 });
expect(await collectionToursApi.save(3, { source_trip_id: 2, source_place_id: 7, name: 'Kept' })).toEqual({
tour: { id: 5 },
});
expect(await collectionToursApi.remove(3, 4)).toBeUndefined();
expect(calls).toEqual([
{ method: 'GET', path: BASE, body: null },
{ method: 'GET', path: `${BASE}/4`, body: null },
{ method: 'POST', path: BASE, body: { source_trip_id: 2, source_place_id: 7, name: 'Kept' } },
{ method: 'DELETE', path: `${BASE}/4`, body: null },
]);
});
it('FE-API-COLLECTION-TOURS-002: takes a kept tour into a trip with the choice about its points', async () => {
server.use(http.post('/api/trips/6/tours/from-collection', record({ tour: { place_id: 9 }, waypoints: [] })));
const body = { collection_id: 3, collection_tour_id: 4, include_waypoints: false };
expect((await collectionToursApi.takeOver(6, body)).tour).toEqual({ place_id: 9 });
expect(calls).toEqual([{ method: 'POST', path: '/api/trips/6/tours/from-collection', body }]);
});
});
+25
View File
@@ -0,0 +1,25 @@
import type {
CollectionTourDetailResponse,
CollectionTourListResponse,
CollectionTourSaveRequest,
CollectionTourSaveResponse,
CollectionTourTakeOverRequest,
TourCreateResponse,
} from '@trek/shared';
import apiClient from './client';
const base = (collectionId: number) => `/addons/collections/${collectionId}/tours`;
/** LIB-03: the whole tours a collection keeps, keeping one, removing one and taking one into a trip. */
export const collectionToursApi = {
list: (collectionId: number, signal?: AbortSignal): Promise<CollectionTourListResponse> =>
apiClient.get(base(collectionId), { signal }).then((r) => r.data),
detail: (collectionId: number, tourId: number, signal?: AbortSignal): Promise<CollectionTourDetailResponse> =>
apiClient.get(`${base(collectionId)}/${tourId}`, { signal }).then((r) => r.data),
save: (collectionId: number, body: CollectionTourSaveRequest): Promise<CollectionTourSaveResponse> =>
apiClient.post(base(collectionId), body).then((r) => r.data),
remove: (collectionId: number, tourId: number): Promise<void> =>
apiClient.delete(`${base(collectionId)}/${tourId}`).then(() => undefined),
takeOver: (tripId: number, body: CollectionTourTakeOverRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/from-collection`, body).then((r) => r.data),
};
+34
View File
@@ -0,0 +1,34 @@
// FE-API-HORSE-001 to FE-API-HORSE-002
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { horseRoutesApi } from './horseRoutes';
const reply = { routes: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
const BBOX = '47.10000,11.20000,47.30000,11.50000';
describe('horseRoutesApi.list', () => {
it('FE-API-HORSE-001: asks GET /api/trips/:tripId/tours/horse-routes with the view and returns the body', async () => {
let url = '';
server.use(
http.get('/api/trips/7/tours/horse-routes', ({ request }) => {
url = request.url;
return HttpResponse.json(reply);
})
);
await expect(horseRoutesApi.list(7, BBOX)).resolves.toEqual(reply);
expect(new URL(url).searchParams.get('bbox')).toBe(BBOX);
});
it('FE-API-HORSE-002: no Overpass mirror answering (502) rejects', async () => {
server.use(
http.get('/api/trips/7/tours/horse-routes', () =>
HttpResponse.json({ error: 'Horse route data is unavailable right now' }, { status: 502 })
)
);
await expect(horseRoutesApi.list(7, BBOX)).rejects.toBeTruthy();
});
});
+21
View File
@@ -0,0 +1,21 @@
import apiClient from './client';
/**
* How long the browser waits for GET /api/trips/:tripId/tours/horse-routes.
* The server races the Overpass mirrors under its own deadline (25 s by
* default) and answers 502 when none made it, so this sits just above it, as
* the via ferrata layer's wait does.
*/
const HORSE_ROUTES_TIMEOUT_MS = 30_000;
/**
* HOR-01 GET /api/trips/:tripId/tours/horse-routes: the signposted horse
* routes OpenStreetMap knows inside one map view. The answer comes back
* unparsed; repo/horseRouteRepo.ts checks it against the shared contract.
*/
export const horseRoutesApi = {
list: (tripId: number | string, bbox: string, signal?: AbortSignal): Promise<unknown> =>
apiClient
.get(`/trips/${tripId}/tours/horse-routes`, { params: { bbox }, signal, timeout: HORSE_ROUTES_TIMEOUT_MS })
.then((response) => response.data as unknown),
};
+27
View File
@@ -0,0 +1,27 @@
// FE-API-JOURNEY-MAP-001
//
// JRN-04: both reads pin their path and hand back what the server answered.
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { journeyMapApi } from './journeyMap';
describe('journeyMapApi', () => {
it('FE-API-JOURNEY-MAP-001: reads the layers of a journey and those of a share link', async () => {
const layers = { places: { status: 'withheld' } };
const paths: string[] = [];
server.use(
http.get('/api/journeys/7/map-layers', ({ request }) => {
paths.push(new URL(request.url).pathname);
return HttpResponse.json(layers);
}),
http.get('/api/public/journey/:token/map-layers', ({ request }) => {
paths.push(new URL(request.url).pathname);
return HttpResponse.json(layers);
})
);
expect(await journeyMapApi.layers(7)).toEqual(layers);
expect(await journeyMapApi.publicLayers('a/b')).toEqual(layers);
expect(paths).toEqual(['/api/journeys/7/map-layers', '/api/public/journey/a%2Fb/map-layers']);
});
});
+10
View File
@@ -0,0 +1,10 @@
import type { JourneyMapLayersResponse, JourneyPublicMapLayersResponse } from '@trek/shared';
import apiClient from './client';
/** JRN-04: what a journey map shows beside its entries and photos, for its people and through a share link. */
export const journeyMapApi = {
layers: (journeyId: number, signal?: AbortSignal): Promise<JourneyMapLayersResponse> =>
apiClient.get(`/journeys/${journeyId}/map-layers`, { signal }).then((r) => r.data),
publicLayers: (token: string, signal?: AbortSignal): Promise<JourneyPublicMapLayersResponse> =>
apiClient.get(`/public/journey/${encodeURIComponent(token)}/map-layers`, { signal }).then((r) => r.data),
};
+85
View File
@@ -0,0 +1,85 @@
// FE-API-JOURNEY-RECORDINGS-001
//
// JRN-01: every method pins its verb, its path and its body, and hands back what
// the server answered.
import { http, HttpResponse, type JsonBodyType } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { journeyRecordingsApi } from './journeyRecordings';
const BASE = '/api/journeys/7/recordings';
const HASH = 'a'.repeat(64);
let calls: Array<{ method: string; path: string; body: unknown }> = [];
beforeEach(() => {
calls = [];
});
function record(data: JsonBodyType) {
return async ({ request }: { request: Request }) => {
const text = await request.text();
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
return HttpResponse.json(data);
};
}
describe('journeyRecordingsApi', () => {
it('FE-API-JOURNEY-RECORDINGS-001: lists, previews, keeps and removes the recorded days of a journey', async () => {
server.use(
http.get(BASE, record({ days: [] })),
http.post(`${BASE}/preview`, record({ date: '2026-05-14' })),
http.put(`${BASE}/2026-05-14`, record({ outcome: 'created' })),
http.delete(`${BASE}/2026-05-14`, record({ success: true }))
);
expect(await journeyRecordingsApi.list(7)).toEqual({ days: [] });
expect(
await journeyRecordingsApi.preview(7, { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true })
).toEqual({ date: '2026-05-14' });
expect(await journeyRecordingsApi.save(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH })).toEqual({
outcome: 'created',
});
expect(await journeyRecordingsApi.remove(7, '2026-05-14')).toBeUndefined();
expect(calls).toEqual([
{ method: 'GET', path: BASE, body: null },
{
method: 'POST',
path: `${BASE}/preview`,
body: { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true },
},
{ method: 'PUT', path: `${BASE}/2026-05-14`, body: { utcOffsetMinutes: 120, expectedHash: HASH } },
{ method: 'DELETE', path: `${BASE}/2026-05-14`, body: null },
]);
});
it('FE-API-JOURNEY-RECORDINGS-002: reviews the stays of a day and takes them over (JRN-02)', async () => {
const body = { groups: [{ kind: 'single' as const, stays: [{ suggestionId: 3 }] }], skipSuggestionIds: [4] };
server.use(
http.post(`${BASE}/2026-05-14/stays/review`, record({ date: '2026-05-14', stays: [] })),
http.post(`${BASE}/2026-05-14/stays/import`, record({ entries: [], alreadyTaken: [3], skipped: [] }))
);
expect(await journeyRecordingsApi.reviewDay(7, '2026-05-14')).toEqual({ date: '2026-05-14', stays: [] });
expect(await journeyRecordingsApi.takeOverDay(7, '2026-05-14', body)).toEqual({
entries: [],
alreadyTaken: [3],
skipped: [],
});
expect(calls).toEqual([
{ method: 'POST', path: `${BASE}/2026-05-14/stays/review`, body: {} },
{ method: 'POST', path: `${BASE}/2026-05-14/stays/import`, body },
]);
});
it('FE-API-JOURNEY-RECORDINGS-003: reads how the journey prepares its days (JRN-03)', async () => {
const answer = {
enabled: false,
connection: 'not_connected',
lastAttemptAt: null,
lastSuccessAt: null,
lastError: null,
};
server.use(http.get(`${BASE}/prep`, record(answer)));
expect(await journeyRecordingsApi.prep(7)).toEqual(answer);
expect(calls).toEqual([{ method: 'GET', path: `${BASE}/prep`, body: null }]);
});
});
+44
View File
@@ -0,0 +1,44 @@
import type {
JourneyDayImportRequest,
JourneyDayImportResult,
JourneyDayStays,
JourneyRecordedDaysResponse,
JourneyRecordingPrep,
JourneyRecordingPreview,
JourneyRecordingPreviewRequest,
JourneyRecordingSaveRequest,
JourneyRecordingSaveResult,
} from '@trek/shared';
import apiClient from './client';
import { DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich';
const base = (journeyId: number) => `/journeys/${journeyId}/recordings`;
/** The preview and the save ask Dawarich first, so they get its longer wait. */
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS };
/**
* JRN-01: the recorded days a journey keeps, previewing one, keeping it and dropping it.
* JRN-02: the stays of a day reviewed and taken over as drafts.
* JRN-03: how the journey prepares its days for the caller.
*/
export const journeyRecordingsApi = {
list: (journeyId: number, signal?: AbortSignal): Promise<JourneyRecordedDaysResponse> =>
apiClient.get(base(journeyId), { signal }).then((r) => r.data),
prep: (journeyId: number, signal?: AbortSignal): Promise<JourneyRecordingPrep> =>
apiClient.get(`${base(journeyId)}/prep`, { signal }).then((r) => r.data),
preview: (
journeyId: number,
body: JourneyRecordingPreviewRequest,
signal?: AbortSignal
): Promise<JourneyRecordingPreview> =>
apiClient.post(`${base(journeyId)}/preview`, body, { ...upstream, signal }).then((r) => r.data),
save: (journeyId: number, date: string, body: JourneyRecordingSaveRequest): Promise<JourneyRecordingSaveResult> =>
apiClient.put(`${base(journeyId)}/${date}`, body, upstream).then((r) => r.data),
remove: (journeyId: number, date: string): Promise<void> =>
apiClient.delete(`${base(journeyId)}/${date}`).then(() => undefined),
reviewDay: (journeyId: number, date: string, signal?: AbortSignal): Promise<JourneyDayStays> =>
apiClient.post(`${base(journeyId)}/${date}/stays/review`, {}, { ...upstream, signal }).then((r) => r.data),
takeOverDay: (journeyId: number, date: string, body: JourneyDayImportRequest): Promise<JourneyDayImportResult> =>
apiClient.post(`${base(journeyId)}/${date}/stays/import`, body).then((r) => r.data),
};
+8
View File
@@ -0,0 +1,8 @@
import { skiRouteResponseSchema, type SkiRouteInput, type SkiRouteResponse } from '@trek/shared';
import apiClient from './client';
/** The piste router: a ski tour's legs planned on the piste data the server keeps, held to the contract. */
export const skiRouteApi = {
plan: (tripId: number | string, request: SkiRouteInput, signal?: AbortSignal): Promise<SkiRouteResponse> =>
apiClient.post(`/trips/${tripId}/ski/route`, request, { signal }).then((r) => skiRouteResponseSchema.parse(r.data)),
};
+14
View File
@@ -0,0 +1,14 @@
import type { TourActivityAssignRequest, TourDetailResponse } from '@trek/shared';
import apiClient from './client';
/** ACT-01: the recorded tracks of a tour, assigned and taken off; both answer the tour's detail. */
export const tourActivitiesApi = {
assign: (
tripId: number | string,
placeId: number | string,
body: TourActivityAssignRequest
): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}/activities`, body).then((r) => r.data),
unassign: (tripId: number | string, placeId: number | string, activityId: number): Promise<TourDetailResponse> =>
apiClient.delete(`/trips/${tripId}/tours/${placeId}/activities/${activityId}`).then((r) => r.data),
};
+10
View File
@@ -0,0 +1,10 @@
import type { TourActivityExclusionRequest, TourActivityStatsResponse } from '@trek/shared';
import apiClient from './client';
/** STA-02: the user's own tour activities across their trips, and leaving one out of them; both answer the sums. */
export const tourActivityStatsApi = {
get: (signal?: AbortSignal): Promise<TourActivityStatsResponse> =>
apiClient.get('/tours/activity-stats', { signal }).then((r) => r.data),
setExcluded: (body: TourActivityExclusionRequest): Promise<TourActivityStatsResponse> =>
apiClient.put('/tours/activity-stats/exclusions', body).then((r) => r.data),
};
+3 -1
View File
@@ -1,7 +1,7 @@
import type { AdminFeatureToggleResponse } from '@trek/shared'; import type { AdminFeatureToggleResponse } from '@trek/shared';
import apiClient from './client'; import apiClient from './client';
/** An admin's switch for a map the Tours planner offers on its operator's terms. */ /** An admin's switch for a map or a service the Tours planner offers on its operator's terms. */
function tourBaseMapAdminApi(path: string) { function tourBaseMapAdminApi(path: string) {
return { return {
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get(path).then((r) => r.data), get: (): Promise<AdminFeatureToggleResponse> => apiClient.get(path).then((r) => r.data),
@@ -14,3 +14,5 @@ function tourBaseMapAdminApi(path: string) {
export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map'); export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map');
/** BIK-06: CyclOSM for a bike tour. */ /** BIK-06: CyclOSM for a bike tour. */
export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map'); export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map');
/** SKI-02: the piste router, which plans ski tours on the Places host's piste data. */
export const skiRoutingAdminApi = tourBaseMapAdminApi('/admin/tours-ski-routing');
+5 -2
View File
@@ -1,7 +1,7 @@
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared'; import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
import { postMultipart } from './client'; import { postMultipart } from './client';
/** A GPX file and the fields beside it, as the Tours import routes read a multipart body. */ /** A GPX, KML or KMZ file and the fields beside it, as the Tours import routes read a multipart body. */
function gpxForm(file: File, fields?: TourGpxImportFields): FormData { function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
@@ -9,7 +9,10 @@ function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
return fd; return fd;
} }
/** The Tours GPX import: a file read for its preview first (PAD-02), then imported as the type chosen. */ /**
* The Tours file import: a GPX, KML or KMZ file (IO-02) read for its preview
* first (PAD-02), then imported as the type chosen.
*/
export const tourGpxApi = { export const tourGpxApi = {
/** What the file holds, read by the import's own parser; nothing is stored. */ /** What the file holds, read by the import's own parser; nothing is stored. */
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> => preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
+20
View File
@@ -0,0 +1,20 @@
import type { TourDetailResponse, TourPackingLinkRequest, TourPackingLinkResponse } from '@trek/shared';
import apiClient from './client';
/**
* Where a tour's packing link lives; a write queued offline replays to the
* same path, `{id}` standing for a tour that has not synced yet.
*/
export function tourPackingLinkPath(tripId: number | string, placeId: number | string): string {
return `/trips/${tripId}/tours/${placeId}/packing-link`;
}
/** P2D-XD-01: the packing list, or the category of it, a tour points at. */
export const tourPackingLinkApi = {
read: (tripId: number | string, placeId: number, signal?: AbortSignal): Promise<TourPackingLinkResponse> =>
apiClient.get(tourPackingLinkPath(tripId, placeId), { signal }).then((r) => r.data),
link: (tripId: number | string, placeId: number, body: TourPackingLinkRequest): Promise<TourDetailResponse> =>
apiClient.put(tourPackingLinkPath(tripId, placeId), body).then((r) => r.data),
unlink: (tripId: number | string, placeId: number): Promise<TourDetailResponse> =>
apiClient.delete(tourPackingLinkPath(tripId, placeId)).then((r) => r.data),
};
+3 -2
View File
@@ -4,9 +4,10 @@ import apiClient from './client';
/** /**
* PLN-02: places along a tour. A search runs several stretches against the map * PLN-02: places along a tour. A search runs several stretches against the map
* search on the server, each allowed its own timeout there, so it may take * search on the server, each allowed its own timeout there, so it may take
* longer than the shared default. * longer than the shared default: up to three rounds of stretches, each asking
* the Overpass mirrors one after another for about twenty seconds at most.
*/ */
const POI_SEARCH_TIMEOUT_MS = 60_000; const POI_SEARCH_TIMEOUT_MS = 75_000;
export const tourPoiSearchApi = { export const tourPoiSearchApi = {
search: (tripId: number | string, body: TourPoiSearchRequest, signal?: AbortSignal): Promise<TourPoiSearchResponse> => search: (tripId: number | string, body: TourPoiSearchRequest, signal?: AbortSignal): Promise<TourPoiSearchResponse> =>
+11
View File
@@ -0,0 +1,11 @@
import type { TourDetailResponse, TourSkiClassificationRequest } from '@trek/shared';
import apiClient from './client';
/** SKI-02: a ski track classified again, its discipline and cross-country technique. */
export const tourSkiApi = {
classify: (
tripId: number | string,
placeId: number | string,
body: TourSkiClassificationRequest
): Promise<TourDetailResponse> => apiClient.put(`/trips/${tripId}/tours/${placeId}/ski`, body).then((r) => r.data),
};
+20
View File
@@ -0,0 +1,20 @@
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
import apiClient from './client';
/** Where one station of a walk is marked for one day; a write queued offline replays to the same path. */
export function tourVisitPath(tripId: number | string, assignmentId: number, stationKey: string): string {
return `/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`;
}
/** CW-03: the stations of a trip's city walks visited or skipped on their days. */
export const tourVisitsApi = {
list: (tripId: number | string): Promise<TourVisitListResponse> =>
apiClient.get(`/trips/${tripId}/tour-visits`).then((r) => r.data),
update: (
tripId: number | string,
assignmentId: number,
stationKey: string,
body: TourVisitUpdateRequest
): Promise<TourVisitListResponse> =>
apiClient.put(tourVisitPath(tripId, assignmentId, stationKey), body).then((r) => r.data),
};
+20
View File
@@ -0,0 +1,20 @@
import type { WalkImportPreviewRequest, WalkImportPreviewResponse } from '@trek/shared';
import apiClient from './client';
/**
* CW-07: a Google Maps walking route read into its stops. The server may
* follow a short link a few hops within its own deadline, so the call waits
* a little longer than that before it gives up.
*/
const WALK_IMPORT_TIMEOUT_MS = 20_000;
export const tourWalkImportApi = {
preview: (
tripId: number | string,
body: WalkImportPreviewRequest,
signal?: AbortSignal
): Promise<WalkImportPreviewResponse> =>
apiClient
.post(`/trips/${tripId}/tours/import/google-maps/preview`, body, { signal, timeout: WALK_IMPORT_TIMEOUT_MS })
.then((r) => r.data),
};
+9
View File
@@ -1,6 +1,7 @@
import type { import type {
AdminFeatureToggleResponse, AdminFeatureToggleResponse,
TourSourceCheckResponse, TourSourceCheckResponse,
TourSourceHintResponse,
WandererConnection, WandererConnection,
WandererImportPreview, WandererImportPreview,
WandererImportRequest, WandererImportRequest,
@@ -11,6 +12,7 @@ import type {
WandererSettingsRequest, WandererSettingsRequest,
WandererTestRequest, WandererTestRequest,
WandererTestResponse, WandererTestResponse,
WandererWatch,
} from '@trek/shared'; } from '@trek/shared';
import apiClient from './client'; import apiClient from './client';
@@ -31,6 +33,10 @@ export const wandererApi = {
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data), disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
test: (data: WandererTestRequest): Promise<WandererTestResponse> => test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data), apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
/** WAN-02: the optional background watch of the connection, with the open hints. */
getWatch: (): Promise<WandererWatch> => apiClient.get('/integrations/wanderer/watch').then((r) => r.data),
setWatch: (enabled: boolean): Promise<WandererWatch> =>
apiClient.put('/integrations/wanderer/watch', { enabled }).then((r) => r.data),
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> => search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data), apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
@@ -43,6 +49,9 @@ export const wandererApi = {
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */ /** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> => checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data), apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
/** WAN-02: the caller's own background hint on a snapshot; the server only reads its own rows. */
sourceHint: (tripId: number, placeId: number, signal?: AbortSignal): Promise<TourSourceHintResponse> =>
apiClient.get(`/trips/${tripId}/tours/wanderer/sources/${placeId}/hint`, { signal }).then((r) => r.data),
}; };
/** The admin's switch for wanderer under the Tours addon. */ /** The admin's switch for wanderer under the Tours addon. */
+5
View File
@@ -19,6 +19,8 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to // Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
// useTourPlaceIds so the active trip refreshes its Tours/Places read model. // useTourPlaceIds so the active trip refreshes its Tours/Places read model.
'tours:changed', 'tours:changed',
// CW-03: store/tourVisitStore (watchTourVisits) keeps the visits store current.
'tours:visits',
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync // Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created', 'collab:note:created',
'collab:note:updated', 'collab:note:updated',
@@ -79,6 +81,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// Capture times landed after a photo import (#1587); the same listener reloads // Capture times landed after a photo import (#1587); the same listener reloads
// the journey so the gallery re-sorts. // the journey so the gallery re-sorts.
'journey:photos:updated', 'journey:photos:updated',
// A day's recorded route was taken over or removed (JRN-01):
// components/Journey/useJourneyRecordings reads the kept days again.
'journey:recording:updated',
// Studio book — components/Studio/useBookStore (its own listener: a client // Studio book — components/Studio/useBookStore (its own listener: a client
// with nothing outstanding takes the new version, one with unsaved edits // with nothing outstanding takes the new version, one with unsaved edits
// deliberately does not and conflicts on its next save instead) // deliberately does not and conflicts on its next save instead)
@@ -78,7 +78,11 @@ beforeEach(() => {
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined); vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
server.use( server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })), http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false })) http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false })),
// The Tours switches load beside the list; unanswered, each would toast the same error as a failed list.
http.get('/api/admin/tours-hiking-map', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/tours-cycle-map', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/tours-ski-routing', () => HttpResponse.json({ enabled: false }))
); );
}); });
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { adminApi } from '../../../api/client'; import { adminApi } from '../../../api/client';
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps'; import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps';
import { wandererAdminApi } from '../../../api/wanderer'; import { wandererAdminApi } from '../../../api/wanderer';
import { useAddonStore } from '../../../store/addonStore'; import { useAddonStore } from '../../../store/addonStore';
import type { Addon } from './addonModel'; import type { Addon } from './addonModel';
@@ -40,6 +40,7 @@ beforeEach(() => {
vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false }); vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false });
vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true }); vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true });
vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true }); vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true });
vi.spyOn(skiRoutingAdminApi, 'get').mockResolvedValue({ enabled: true });
}); });
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
@@ -52,7 +53,7 @@ async function loaded() {
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled; const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
/** One of the map switches under Tours, by its key. */ /** One of the map switches under Tours, by its key. */
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle') { function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle' | 'ski') {
const found = manager.mapSwitches.find((entry) => entry.key === key); const found = manager.mapSwitches.find((entry) => entry.key === key);
if (!found) throw new Error(`no ${key} map switch`); if (!found) throw new Error(`no ${key} map switch`);
return found; return found;
@@ -149,6 +150,16 @@ describe('useAddonManager', () => {
expect(mapSwitch(result.current, 'hiking').enabled).toBe(true); expect(mapSwitch(result.current, 'hiking').enabled).toBe(true);
}); });
it('FE-HOOK-ADDONMGR-010: the piste router switch under Tours loads on, flips and saves on its own (SKI-02)', async () => {
const update = vi.spyOn(skiRoutingAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(mapSwitch(result.current, 'ski').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'ski').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(mapSwitch(result.current, 'ski').enabled).toBe(false);
expect(mapSwitch(result.current, 'cycle').enabled).toBe(true);
});
it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => { it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => {
vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500')); vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500'));
const { result } = await loaded(); const { result } = await loaded();
@@ -1,14 +1,27 @@
import { Bike, Footprints } from 'lucide-react'; import type { AdminFeatureToggleResponse } from '@trek/shared';
import { Bike, Footprints, MountainSnow } from 'lucide-react';
import { useEffect, useEffectEvent, useState } from 'react'; import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client'; import { adminApi } from '../../../api/client';
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps'; import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps';
import { wandererAdminApi } from '../../../api/wanderer'; import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore'; import { useAddonStore } from '../../../store/addonStore';
import { useToast } from '../../shared/Toast'; import { useToast } from '../../shared/Toast';
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel'; import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
function loadToursFlag(
api: { get: () => Promise<AdminFeatureToggleResponse> },
set: (enabled: boolean) => void,
onError: () => void
) {
api
.get()
.then((flag) => set(flag.enabled))
.catch(onError);
}
/** /**
* The addon switches behind both admin shells: the addon list loaded on mount, an * The addon switches behind both admin shells: the addon list loaded on mount, an
* optimistic toggle with a per row rollback, and the lists each shell groups its tiles * optimistic toggle with a per row rollback, and the lists each shell groups its tiles
@@ -24,6 +37,7 @@ export function useAddonManager() {
const [toursWanderer, setToursWanderer] = useState(false); const [toursWanderer, setToursWanderer] = useState(false);
const [toursHikingMap, setToursHikingMap] = useState(false); const [toursHikingMap, setToursHikingMap] = useState(false);
const [toursCycleMap, setToursCycleMap] = useState(false); const [toursCycleMap, setToursCycleMap] = useState(false);
const [toursSkiRouting, setToursSkiRouting] = useState(false);
const loadAddons = async () => { const loadAddons = async () => {
try { try {
@@ -36,18 +50,11 @@ export function useAddonManager() {
const loadOnMount = useEffectEvent(() => { const loadOnMount = useEffectEvent(() => {
void loadAddons().finally(() => setLoading(false)); void loadAddons().finally(() => setLoading(false));
wandererAdminApi const unreadable = () => toast.error(t('admin.addons.toast.error'));
.get() loadToursFlag(wandererAdminApi, setToursWanderer, unreadable);
.then((flag) => setToursWanderer(flag.enabled)) loadToursFlag(hikingMapAdminApi, setToursHikingMap, unreadable);
.catch(() => toast.error(t('admin.addons.toast.error'))); loadToursFlag(cycleMapAdminApi, setToursCycleMap, unreadable);
hikingMapAdminApi loadToursFlag(skiRoutingAdminApi, setToursSkiRouting, unreadable);
.get()
.then((flag) => setToursHikingMap(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
cycleMapAdminApi
.get()
.then((flag) => setToursCycleMap(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
}); });
useEffect(() => { useEffect(() => {
loadOnMount(); loadOnMount();
@@ -94,7 +101,7 @@ export function useAddonManager() {
toast.success(t('admin.addons.toast.updated')); toast.success(t('admin.addons.toast.updated'));
}; };
const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update); const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update);
/** The maps the Tours planner offers on their operators' terms, one switch each (MAP-01, BIK-06). */ /** The maps and the piste router Tours offers on their operators' terms, one switch each (MAP-01, BIK-06, SKI-02). */
const mapSwitches = [ const mapSwitches = [
{ {
key: 'hiking', key: 'hiking',
@@ -112,6 +119,14 @@ export function useAddonManager() {
enabled: toursCycleMap, enabled: toursCycleMap,
toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update), toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update),
}, },
{
key: 'ski',
Icon: MountainSnow,
titleKey: 'admin.toursSkiRouting.title',
subtitleKey: 'admin.toursSkiRouting.subtitle',
enabled: toursSkiRouting,
toggle: () => flipToursSwitch(toursSkiRouting, setToursSkiRouting, skiRoutingAdminApi.update),
},
] as const; ] as const;
const asOption = (provider: Addon): ProviderOption => ({ const asOption = (provider: Addon): ProviderOption => ({
@@ -0,0 +1,150 @@
// FE-STA2-PANE-001 to FE-STA2-PANE-006
// STA-02: the Atlas tab of the user's own tour activities: the distance large
// with how many tours at its corner, the climb, the types, what the numbers
// rest on, and the full list in a dialog.
import userEvent from '@testing-library/user-event';
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildSettings } from '../../../../tests/helpers/factories';
import { render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { AtlasTourActivityPane } from './AtlasTourActivityPane';
import type { TourActivityStats } from './useTourActivityStats';
const hook = vi.hoisted(() => ({ current: null as unknown, active: [] as boolean[] }));
vi.mock('./useTourActivityStats', () => ({
useTourActivityStats: (active: boolean) => {
hook.active.push(active);
return hook.current;
},
}));
const TODAY = '2026-10-20';
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
derived_from_place_id: null,
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 12,
ascent_m: 800,
descent_m: 790,
excluded: false,
...overrides,
};
}
function statsOf(data: TourActivityStatsResponse | null, overrides: Partial<TourActivityStats> = {}) {
const stats: TourActivityStats = {
status: data ? 'ready' : 'loading',
data,
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
period: 'all',
setPeriod: vi.fn(),
periods: ['all', 'thisYear', 'last12'],
type: 'all',
setType: vi.fn(),
types: ['all', 'hike', 'kayak'],
pending: new Set(),
setExcluded: vi.fn().mockResolvedValue(undefined),
retry: vi.fn(),
...overrides,
};
hook.current = stats;
return stats;
}
const data: TourActivityStatsResponse = {
entries: [
entry(),
entry({ place_id: 2, name: 'Summit', date: '2026-08-01', distance_km: 20.5, ascent_m: 1200, descent_m: 1200 }),
entry({
place_id: 3,
name: 'Lake',
tour_type: 'kayak',
basis: 'track',
track: { place_id: 9, name: 'Watch' },
distance_km: 8,
ascent_m: null,
descent_m: null,
}),
],
uncounted: { unassigned: 0, undated: 1 },
};
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
hook.active = [];
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
});
describe('AtlasTourActivityPane', () => {
it('FE-STA2-PANE-001: follows whether the tab is shown, silent before, loading until the read is there', () => {
statsOf(null, { status: 'idle' });
const { rerender } = render(<AtlasTourActivityPane active={false} />);
expect(hook.active).toEqual([false]);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
statsOf(null);
rerender(<AtlasTourActivityPane active />);
expect(hook.active).toEqual([false, true]);
expect(screen.getByRole('status')).toHaveTextContent('Loading');
});
it('FE-STA2-PANE-002: shows the distance large with the tour count at its corner, and the climb', () => {
statsOf(data);
render(<AtlasTourActivityPane active />);
expect(screen.getByTestId('atlas-tour-activity-distance')).toHaveTextContent('40.5km');
expect(screen.getByTestId('atlas-tour-activity-count')).toHaveTextContent('3');
expect(screen.getByTestId('atlas-tour-activity-ascent')).toHaveTextContent('2000m');
expect(screen.getByTestId('atlas-tour-activity-type-hike')).toHaveTextContent('2');
expect(screen.getByTestId('atlas-tour-activity-type-kayak')).toHaveTextContent('8 km');
});
it('FE-STA2-PANE-003: says what the numbers rest on and what does not count', () => {
statsOf(data);
render(<AtlasTourActivityPane active />);
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('Planned2');
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('Recorded1');
expect(screen.getByTestId('tour-activity-undated')).toHaveTextContent('1');
});
it('FE-STA2-PANE-004: one type chosen leaves the type columns out; nothing done says so', () => {
statsOf(data, { type: 'hike', summary: summarizeTourActivities(data.entries, { today: TODAY, type: 'hike' }) });
const { rerender } = render(<AtlasTourActivityPane active />);
expect(screen.queryByTestId('atlas-tour-activity-type-hike')).not.toBeInTheDocument();
statsOf(data, { summary: summarizeTourActivities(data.entries, { today: TODAY, from: '2027-01-01' }) });
rerender(<AtlasTourActivityPane active />);
expect(screen.getByText('No completed tours in this period')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Show tours' })).not.toBeInTheDocument();
});
it('FE-STA2-PANE-005: the full list opens in the dialog', async () => {
statsOf(data);
const user = userEvent.setup();
render(<AtlasTourActivityPane active />);
await user.click(screen.getByRole('button', { name: 'Show tours' }));
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-list')).toBeInTheDocument();
});
it('FE-STA2-PANE-006: offline and failed reads say so, a failed one with a retry', async () => {
statsOf(null, { status: 'offline' });
const { rerender } = render(<AtlasTourActivityPane active />);
expect(screen.getByText(/needs a connection/i)).toBeInTheDocument();
const failed = statsOf(null, { status: 'error' });
rerender(<AtlasTourActivityPane active />);
expect(screen.getByRole('alert')).toHaveTextContent('Tour activities could not be loaded');
await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' }));
expect(failed.retry).toHaveBeenCalled();
});
});
@@ -0,0 +1,223 @@
import type { TourActivitySummary } from '@trek/shared';
import { Footprints, ListOrdered } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { CardAction, InfoDot } from '../../Tours/tourParts';
import { ActivityFilters, ActivityStatusLine, BasisBadges, UncountedBadges } from './tourActivityParts';
import { distanceReading, heightReading, typeIcon, typeLabel, useDistanceUnit } from './tourActivityReading';
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
import { splitMeasure } from './tourActivityStatsModel';
import { useTourActivityStats, type TourActivityStats } from './useTourActivityStats';
/** The wash the hero tile sits on, as the Atlas numbers do: the text colour, faintly, over the glass. */
const HERO_TINT = 'color-mix(in srgb, var(--text-primary) 7%, transparent)';
/** How many types the strip names before the dialog takes over. */
const STRIP_TYPES = 3;
const EYEBROW = 'mt-1.5 whitespace-nowrap font-semibold uppercase tracking-wide text-content-faint';
function Divider() {
return <span aria-hidden="true" className="mx-3 my-3 w-0.5 flex-none rounded-full bg-edge-faint" />;
}
/**
* The distance of the period, large, with how many tours at its corner; the
* tour count stands in when no tour knows its distance.
*/
function Hero({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
const { t } = useTranslation();
const reading = distanceReading(summary.totals, unit);
const shown = reading ? splitMeasure(reading.text) : { value: String(summary.totals.count), unit: '' };
const countLabel = `${t('tours.mode.tours')}: ${summary.totals.count}`;
return (
<div
className="relative mx-2 my-2 flex flex-col justify-center rounded-xl px-5 py-4"
style={{ background: HERO_TINT }}
>
<div className="flex items-baseline gap-1.5" data-testid="atlas-tour-activity-distance">
<span className="font-black tabular-nums leading-none text-content" style={fs(40, 'title')}>
{shown.value}
</span>
{shown.unit && (
<span className="font-medium text-content-muted" style={fs(14, 'body')}>
{shown.unit}
</span>
)}
</div>
<span className={EYEBROW} style={fs(9)}>
{reading ? t('tours.detail.distance') : t('tours.mode.tours')}
</span>
{reading && (
<Tooltip label={countLabel}>
<span
aria-label={countLabel}
data-testid="atlas-tour-activity-count"
className="absolute -end-2 -top-2 inline-flex h-[22px] items-center gap-1 rounded-full bg-surface-card px-2 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-inset ring-edge-faint"
style={fs(11)}
>
<Footprints size={11} strokeWidth={2.2} className="flex-none text-content-muted" />
{summary.totals.count}
</span>
</Tooltip>
)}
</div>
);
}
/** A height sum as the Atlas numbers stand: the value, its unit, and what it is below. */
function Figure({ label, text, testId }: { label: string; text: string; testId: string }) {
const { value, unit } = splitMeasure(text);
return (
<div className="flex flex-none flex-col items-center justify-center px-3 py-4" data-testid={testId}>
<span className="flex items-baseline gap-1">
<span className="font-black tabular-nums leading-none text-content" style={fs(24, 'subtitle')}>
{value}
</span>
<span className="font-medium text-content-muted" style={fs(11, 'body')}>
{unit}
</span>
</span>
<span className={EYEBROW} style={fs(9)}>
{label}
</span>
</div>
);
}
/** The types of the period, the most tours first: each an icon with its count at the corner, its distance below. */
function TypeColumns({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
const { t } = useTranslation();
return (
<div className="flex flex-none items-center gap-4 px-2 py-3">
{summary.types.slice(0, STRIP_TYPES).map((scope) => {
const Icon = typeIcon(scope.type);
const distance = distanceReading(scope, unit);
const label = `${typeLabel(scope.type, t)}: ${scope.count}${distance ? `, ${distance.text}` : ''}`;
return (
<Tooltip key={scope.type} label={label}>
<span
aria-label={label}
className="flex flex-col items-center gap-1.5"
data-testid={`atlas-tour-activity-type-${scope.type}`}
>
<span className="relative">
<span className="grid h-8 w-8 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint">
<Icon size={15} strokeWidth={1.9} />
</span>
<span
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-surface-card"
style={fs(9)}
>
{scope.count}
</span>
</span>
{distance && (
<span
className="whitespace-nowrap font-semibold tabular-nums text-content-secondary"
style={fs(11, 'body')}
>
{distance.text}
</span>
)}
</span>
</Tooltip>
);
})}
</div>
);
}
/** What the sums rest on, what does not count, and the way into the full list. */
interface StripProps {
stats: TourActivityStats;
summary: TourActivitySummary;
onOpen: () => void;
}
function SideColumn({ stats, summary, onOpen }: StripProps) {
const { t } = useTranslation();
const uncounted = stats.data?.uncounted ?? { unassigned: 0, undated: 0 };
return (
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
<div className="flex flex-wrap items-center gap-1.5">
<BasisBadges byBasis={summary.totals.byBasis} />
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
</div>
<div className="flex flex-wrap items-center gap-1.5">
<UncountedBadges upcoming={summary.upcoming} {...uncounted} />
</div>
{summary.done.length > 0 && (
<CardAction icon={<ListOrdered size={13} strokeWidth={2.2} />} onClick={onOpen}>
{t('tours.stats.showTours')}
</CardAction>
)}
</div>
);
}
/** The numbers of the period, or why there are none, with the filters before them. */
function Strip({ stats, summary, onOpen }: StripProps) {
const { t } = useTranslation();
const unit = useDistanceUnit();
const ascent = heightReading(summary.totals.ascentM, unit);
const descent = heightReading(summary.totals.descentM, unit);
const counted = summary.totals.count > 0;
return (
<>
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
<ActivityFilters stats={stats} quiet />
</div>
{counted ? (
<>
<Hero summary={summary} unit={unit} />
{ascent && <Figure label={t('tours.detail.ascent')} text={ascent.text} testId="atlas-tour-activity-ascent" />}
{descent && (
<Figure label={t('tours.detail.descent')} text={descent.text} testId="atlas-tour-activity-descent" />
)}
{stats.type === 'all' && summary.types.length > 1 && (
<>
<Divider />
<TypeColumns summary={summary} unit={unit} />
</>
)}
</>
) : (
<p className="m-0 flex max-w-[260px] items-center px-4 py-5 text-content-muted" style={fs(12, 'body')}>
{t(stats.data?.entries.length ? 'tours.stats.none' : 'tours.stats.empty')}
</p>
)}
<Divider />
<SideColumn stats={stats} summary={summary} onOpen={onOpen} />
</>
);
}
/**
* STA-02: the Atlas tab of the user's own tour activities: the period and type,
* the distance done with how many tours at its corner, the climb, the types,
* and what the numbers rest on (the trip planning, or recorded tracks where
* linked). The full list, where a tour can be left out, opens in a dialog. The
* read waits until the tab is first shown.
*/
export function AtlasTourActivityPane({ active, maxWidth }: { active: boolean; maxWidth?: number }) {
const stats = useTourActivityStats(active);
const [open, setOpen] = useState(false);
return (
<div
className="flex w-full items-stretch justify-center overflow-x-auto px-2"
style={{ maxWidth }}
data-testid="atlas-tour-activity"
>
{stats.summary ? (
<Strip stats={stats} summary={stats.summary} onOpen={() => setOpen(true)} />
) : (
<div className="flex items-center px-6 py-5">
<ActivityStatusLine stats={stats} />
</div>
)}
<TourActivityStatsDialog stats={stats} open={open} onClose={() => setOpen(false)} />
</div>
);
}
@@ -0,0 +1,159 @@
import type { TourActivityStatsEntry } from '@trek/shared';
import { Ban, Briefcase, CalendarDays, Footprints, Loader2, Repeat, RotateCcw } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../../i18n';
import { formatDate } from '../../../utils/formatters';
import { formatDistance, formatElevation } from '../../../utils/units';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { EntryTile, MeasurePills } from './tourActivityParts';
import { useDistanceUnit } from './tourActivityReading';
const ROW_BADGE_ICON = 'flex-none text-content-faint';
/** What the entry's values rest on, as a pill: the plan, or the track that replaced it, named in the tooltip. */
function BasisPill({ entry }: { entry: TourActivityStatsEntry }) {
const { t } = useTranslation();
const track = entry.basis === 'track';
const hint = track
? t('tours.stats.basisTrackHint', { name: entry.track?.name ?? '' })
: t('tours.stats.basisPlanHint');
return (
<Tooltip label={hint}>
<span
className="inline-flex"
aria-label={hint}
data-testid={`tour-activity-basis-${entry.basis}-${entry.place_id}`}
>
<SoftPill
tone={track ? 'success' : 'neutral'}
icon={
track ? (
<Footprints size={10} strokeWidth={2.2} className="flex-none" />
) : (
<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />
)
}
>
{t(track ? 'tours.stats.basisTrack' : 'tours.stats.basisPlan')}
</SoftPill>
</span>
</Tooltip>
);
}
/** The day it counts on, the trip it is in (a way into that trip), and how many days the plan has it on. */
function EntryFacts({ entry }: { entry: TourActivityStatsEntry }) {
const { t, locale } = useTranslation();
const navigate = useNavigate();
return (
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
<WhiteBadge icon={<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
{formatDate(entry.date, locale) ?? entry.date}
</WhiteBadge>
<Tooltip label={t('tours.stats.openTrip')}>
<button
type="button"
onClick={() => void navigate(`/trips/${entry.trip.id}`)}
className="inline-flex min-w-0 max-w-[220px] rounded-full transition-opacity hover:opacity-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<WhiteBadge
className="min-w-0 max-w-full"
icon={<Briefcase size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}
>
<span className="truncate">{entry.trip.title}</span>
</WhiteBadge>
</button>
</Tooltip>
{entry.day_count > 1 && (
<Tooltip label={t('tours.stats.multiDayHint')}>
<span className="inline-flex" aria-label={t('tours.stats.multiDayHint')}>
<WhiteBadge icon={<Repeat size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
{entry.day_count}
</WhiteBadge>
</span>
</Tooltip>
)}
<BasisPill entry={entry} />
{entry.excluded && <SoftPill caps>{t('tours.stats.excluded')}</SoftPill>}
</span>
);
}
/** The round button that leaves the tour out, or counts it again; named by its tooltip. */
function ExcludeToggle({
entry,
busy,
onToggle,
}: {
entry: TourActivityStatsEntry;
busy: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const label = entry.excluded ? t('tours.stats.include') : t('tours.stats.exclude');
const hint = entry.excluded ? label : `${label}: ${t('tours.stats.excludeHint')}`;
let icon = <Ban size={14} strokeWidth={2} />;
if (busy) icon = <Loader2 size={14} strokeWidth={2} className="animate-spin" />;
else if (entry.excluded) icon = <RotateCcw size={14} strokeWidth={2} />;
return (
<Tooltip label={hint}>
<button
type="button"
onClick={onToggle}
disabled={busy}
aria-label={label}
aria-pressed={entry.excluded}
data-testid={`tour-activity-toggle-${entry.place_id}`}
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50"
>
{icon}
</button>
</Tooltip>
);
}
/**
* STA-02: one tour of the user's activities: its type, name and where it
* counts, its distance and climb, and the switch to leave it out. A tour left
* out stays in the list, dimmed, so it can be counted again in place.
*/
export function TourActivityEntryRow({
entry,
busy,
onToggleExcluded,
}: {
entry: TourActivityStatsEntry;
busy: boolean;
onToggleExcluded: (entry: TourActivityStatsEntry) => void;
}) {
const unit = useDistanceUnit();
const key = entry.track ? `${entry.place_id}-${entry.track.place_id}` : String(entry.place_id);
return (
<li
data-testid={`tour-activity-row-${key}`}
className="flex min-w-0 items-center gap-3 rounded-[12px] px-2.5 py-2 transition-colors hover:bg-surface-hover"
>
<span
className={`flex min-w-0 flex-1 items-center gap-3 transition-opacity ${entry.excluded ? 'opacity-50' : ''}`}
>
<EntryTile entry={entry} />
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>
{entry.name}
</span>
<EntryFacts entry={entry} />
</span>
<span className="flex flex-none flex-col items-end gap-1">
<MeasurePills
distance={entry.distance_km === null ? null : formatDistance(entry.distance_km, unit)}
ascent={entry.ascent_m === null ? null : formatElevation(entry.ascent_m, unit)}
testId={`tour-activity-${key}`}
/>
</span>
</span>
<ExcludeToggle entry={entry} busy={busy} onToggle={() => onToggleExcluded(entry)} />
</li>
);
}
@@ -0,0 +1,177 @@
// FE-STA2-DIALOG-001 to FE-STA2-DIALOG-008
// STA-02: the full list of the user's tour activities: the sums with what they
// rest on, the split by type, each tour with its basis and a switch to leave it
// out (dimmed, never hidden), and the way into its trip.
import userEvent from '@testing-library/user-event';
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildSettings } from '../../../../tests/helpers/factories';
import { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useSettingsStore } from '../../../store/settingsStore';
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
import type { TourActivityStats } from './useTourActivityStats';
const navigate = vi.hoisted(() => vi.fn());
vi.mock('react-router', async (importOriginal) => ({
...(await importOriginal<typeof import('react-router')>()),
useNavigate: () => navigate,
}));
const TODAY = '2026-10-20';
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
derived_from_place_id: null,
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 12,
ascent_m: 800,
descent_m: 790,
excluded: false,
...overrides,
};
}
function statsOf(
data: TourActivityStatsResponse | null,
overrides: Partial<TourActivityStats> = {}
): TourActivityStats {
return {
status: data ? 'ready' : 'loading',
data,
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
period: 'all',
setPeriod: vi.fn(),
periods: ['all', 'thisYear', 'last12'],
type: 'all',
setType: vi.fn(),
types: ['all', 'hike', 'kayak'],
pending: new Set(),
setExcluded: vi.fn().mockResolvedValue(undefined),
retry: vi.fn(),
...overrides,
};
}
const data: TourActivityStatsResponse = {
entries: [
entry(),
entry({
place_id: 2,
name: 'Lake',
tour_type: 'kayak',
date: '2026-08-01',
basis: 'track',
track: { place_id: 9, name: 'Watch export' },
distance_km: 8.5,
ascent_m: null,
descent_m: null,
}),
entry({ place_id: 3, name: 'Long trail', date: '2026-06-01', day_count: 3, distance_km: null }),
entry({ place_id: 4, name: 'Rained off', date: '2026-05-01', excluded: true }),
entry({ place_id: 5, name: 'Next week', date: '2026-10-27' }),
],
uncounted: { unassigned: 2, undated: 0 },
};
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
});
describe('TourActivityStatsDialog', () => {
it('FE-STA2-DIALOG-001: shows the sums, at least where a tour lacks the distance, and what they rest on', () => {
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
expect(screen.getByText('≥ 20.5 km')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('2');
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('1');
expect(screen.getByTestId('tour-activity-upcoming')).toHaveTextContent('1');
expect(screen.getByTestId('tour-activity-unassigned')).toHaveTextContent('2');
expect(screen.queryByTestId('tour-activity-undated')).not.toBeInTheDocument();
});
it('FE-STA2-DIALOG-002: splits by type, the count at the corner of each type', () => {
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
expect(within(screen.getByTestId('tour-activity-type-hike')).getByText('2')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-type-kayak-distance')).toHaveTextContent('8.5 km');
expect(screen.queryByTestId('tour-activity-type-kayak-ascent')).not.toBeInTheDocument();
});
it('FE-STA2-DIALOG-003: lists every done tour, newest first, with its basis; the one ahead is not there', () => {
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
const list = screen.getByTestId('tour-activity-list');
expect(within(list).getAllByRole('listitem')).toHaveLength(4);
expect(screen.queryByText('Next week')).not.toBeInTheDocument();
expect(screen.getByTestId('tour-activity-row-2-9')).toHaveTextContent('Recorded');
expect(screen.getByTestId('tour-activity-track-corner')).toBeInTheDocument();
expect(screen.getByTestId('tour-activity-row-1')).toHaveTextContent('Planned');
expect(
within(screen.getByTestId('tour-activity-row-3')).getByLabelText('Planned on several days, counted once')
).toHaveTextContent('3');
expect(screen.queryByTestId('tour-activity-3-distance')).not.toBeInTheDocument();
});
it('FE-STA2-DIALOG-004: a tour left out stays listed and can be counted again, a counted one left out', async () => {
const stats = statsOf(data);
const user = userEvent.setup();
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
expect(screen.getByTestId('tour-activity-row-4')).toHaveTextContent('Left out');
await user.click(screen.getByRole('button', { name: 'Count again' }));
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4 }), false);
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByRole('button', { name: 'Leave out' }));
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 1 }), true);
});
it('FE-STA2-DIALOG-005: a tour on its way to the server cannot be switched twice', () => {
render(<TourActivityStatsDialog stats={statsOf(data, { pending: new Set([1]) })} open onClose={vi.fn()} />);
expect(screen.getByTestId('tour-activity-toggle-1')).toBeDisabled();
});
it('FE-STA2-DIALOG-006: the trip of a tour opens from its badge', async () => {
const user = userEvent.setup();
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByText('Alps'));
expect(navigate).toHaveBeenCalledWith('/trips/5');
});
it('FE-STA2-DIALOG-007: the filters sit on the head band and change period and type', async () => {
const stats = statsOf(data);
const user = userEvent.setup();
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /Period/ }));
await user.click(screen.getByRole('button', { name: 'This year' }));
expect(stats.setPeriod).toHaveBeenCalledWith('thisYear');
await user.click(screen.getByRole('button', { name: /Type/ }));
await user.click(screen.getByRole('button', { name: 'Paddle tour' }));
expect(stats.setType).toHaveBeenCalledWith('kayak');
});
it('FE-STA2-DIALOG-008: before the read, while empty, and closed', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
const { rerender } = render(<TourActivityStatsDialog stats={statsOf(null)} open onClose={onClose} />);
expect(screen.getByRole('status')).toHaveTextContent('Loading');
rerender(
<TourActivityStatsDialog
stats={statsOf({ entries: [], uncounted: { unassigned: 0, undated: 0 } })}
open
onClose={onClose}
/>
);
expect(screen.getByText('No completed tours yet')).toBeInTheDocument();
const [closeButton] = screen.getAllByRole('button', { name: 'Close' });
await user.click(closeButton as HTMLElement);
expect(onClose).toHaveBeenCalled();
rerender(<TourActivityStatsDialog stats={statsOf(null)} open={false} onClose={onClose} />);
expect(screen.queryByText('My tour activities')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,180 @@
import type { TourActivityTotals, TourTypeKey } from '@trek/shared';
import { Footprints } from 'lucide-react';
import { createElement, useId } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { InfoDot, TourFactFields, type TourMetricField } from '../../Tours/tourParts';
import { TourActivityEntryRow } from './TourActivityEntryRow';
import { ActivityFilters, ActivityStatusLine, BasisBadges, MeasurePills, UncountedBadges } from './tourActivityParts';
import { distanceReading, heightReading, partialHint, typeIcon, useDistanceUnit } from './tourActivityReading';
import type { TourActivityStats } from './useTourActivityStats';
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The sums as labelled fields; a sum nobody knows is left out, a short one says at least and why. */
function totalFields(totals: TourActivityTotals, unit: DistanceUnit, t: Translate): TourMetricField[] {
const fields: TourMetricField[] = [{ label: t('tours.mode.tours'), value: String(totals.count) }];
const sums = [
{ label: t('tours.detail.distance'), sum: totals.distanceKm, reading: distanceReading(totals, unit) },
{ label: t('tours.detail.ascent'), sum: totals.ascentM, reading: heightReading(totals.ascentM, unit) },
{ label: t('tours.detail.descent'), sum: totals.descentM, reading: heightReading(totals.descentM, unit) },
];
for (const { label, sum, reading } of sums) {
if (!reading) continue;
const info = reading.partial ? { label, description: partialHint(sum, t) } : undefined;
fields.push({ label, value: reading.text, info });
}
return fields;
}
/** One type of the period: its icon with how many tours at the corner, its name, its distance and climb. */
function TypeLine({ type, totals }: { type: TourTypeKey; totals: TourActivityTotals }) {
const { t } = useTranslation();
const unit = useDistanceUnit();
const distance = distanceReading(totals, unit);
const ascent = heightReading(totals.ascentM, unit);
return (
<li className="flex min-w-0 items-center gap-3 py-1" data-testid={`tour-activity-type-${type}`}>
<span className="relative flex-none">
<span className="grid h-[30px] w-[30px] place-items-center rounded-full bg-edge text-content-secondary">
{createElement(typeIcon(type), { size: 14, strokeWidth: 1.9 })}
</span>
<span
aria-hidden="true"
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-surface-card px-1 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-edge-faint"
style={fs(9)}
>
{totals.count}
</span>
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
{t(`tourTypes.${type}`)}
<span className="sr-only">{`: ${t('tours.mode.tours')} ${totals.count}`}</span>
</span>
<span className="flex flex-none items-center gap-1.5">
<MeasurePills
distance={distance?.text ?? null}
ascent={ascent?.text ?? null}
testId={`tour-activity-type-${type}`}
/>
</span>
</li>
);
}
/** The sums, what they rest on, the split by type, and every tour of the period with its switch. */
function StatsBody({ stats }: { stats: TourActivityStats }) {
const { t } = useTranslation();
const unit = useDistanceUnit();
const { summary, data } = stats;
if (!summary || !data) return <ActivityStatusLine stats={stats} />;
const showTypes = stats.type === 'all' && summary.types.length > 1;
return (
<>
<div className="flex flex-col gap-2.5">
<TourFactFields fields={totalFields(summary.totals, unit, t)} />
<div className="flex flex-wrap items-center gap-1.5">
<BasisBadges byBasis={summary.totals.byBasis} />
<UncountedBadges upcoming={summary.upcoming} {...data.uncounted} />
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
</div>
</div>
{showTypes && (
<DialogSection label={t('tours.stats.byType')}>
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{summary.types.map(({ type, ...totals }) => (
<TypeLine key={type} type={type} totals={totals} />
))}
</ul>
</DialogSection>
)}
<DialogSection
label={t('tours.mode.tours')}
action={
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
{summary.done.length}
</span>
}
>
{summary.done.length === 0 ? (
<p className="m-0 py-4 text-center text-content-muted" style={fs(12.5, 'body')}>
{t(data.entries.length === 0 ? 'tours.stats.empty' : 'tours.stats.none')}
</p>
) : (
<ul className="-mx-2.5 my-0 flex list-none flex-col gap-0.5 p-0" data-testid="tour-activity-list">
{summary.done.map((entry) => (
<TourActivityEntryRow
key={entry.track ? `${entry.place_id}-${entry.track.place_id}` : entry.place_id}
entry={entry}
busy={stats.pending.has(entry.place_id)}
onToggleExcluded={(target) => void stats.setExcluded(target, !target.excluded)}
/>
))}
</ul>
)}
</DialogSection>
</>
);
}
/**
* STA-02: the user's tour activities in full, opened from the Atlas: the
* period and type on the head band, the sums with what they rest on, the
* split by type, and every tour done in the period, each one able to be left
* out (a tour that fell through) and counted again without touching its plan.
*/
export function TourActivityStatsDialog({
stats,
open,
onClose,
}: {
stats: TourActivityStats;
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const labelId = useId();
return (
<DialogShell
open={open}
onClose={onClose}
labelledBy={labelId}
width="editor"
header={
<DialogHeader
tile={
<DialogTile>
<Footprints size={20} strokeWidth={2} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
title={t('tours.stats.title')}
sub={t('tours.stats.sub')}
subWraps
pills={<ActivityFilters stats={stats} />}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.close')}</DialogButton>
</DialogFooter>
}
>
<StatsBody stats={stats} />
</DialogShell>
);
}
@@ -0,0 +1,232 @@
import type { TourActivityBasis, TourActivityStatsEntry } from '@trek/shared';
import {
AlertTriangle,
ArrowUp,
CalendarClock,
CalendarDays,
CalendarRange,
Footprints,
Loader2,
Ruler,
WifiOff,
} from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../../i18n';
import { PillSelect } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { FigureBadge } from '../../Tours/paddle/FigureBadge';
import { TourDayRowPill } from '../../Tours/TourDayRowFacts';
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
import { typeIcon, typeLabel, type Translate } from './tourActivityReading';
import { periodYear, type ActivityPeriod, type ActivityType } from './tourActivityStatsModel';
import type { TourActivityStats } from './useTourActivityStats';
/** What a period is called in the filter. */
function periodLabel(period: ActivityPeriod, t: Translate): string {
if (period === 'all') return t('tours.stats.period.all');
if (period === 'thisYear') return t('tours.stats.period.thisYear');
if (period === 'last12') return t('tours.stats.period.last12');
return String(periodYear(period));
}
/** The period and type filters, as head band pills or quiet ones in a panel. */
export function ActivityFilters({ stats, quiet = false }: { stats: TourActivityStats; quiet?: boolean }) {
const { t } = useTranslation();
const typeOption = (type: ActivityType) => ({
value: type,
label: typeLabel(type, t),
icon: createElement(typeIcon(type), { size: 12, strokeWidth: 2.2, className: 'flex-none text-content-muted' }),
});
return (
<>
<PillSelect
quiet={quiet}
label={t('tours.stats.period')}
value={stats.period}
options={stats.periods.map((period) => ({
value: period,
label: periodLabel(period, t),
icon: <CalendarRange size={12} strokeWidth={2.2} className="flex-none text-content-muted" />,
}))}
fallback={{ label: periodLabel(stats.period, t) }}
onChange={stats.setPeriod}
/>
<PillSelect
quiet={quiet}
label={t('tours.filter.type')}
value={stats.type}
options={stats.types.map(typeOption)}
fallback={typeOption(stats.type)}
onChange={stats.setType}
/>
</>
);
}
/** A distance and a climb as white badges, each named in full by its tooltip; one not known is left out. */
export function MeasurePills({
distance,
ascent,
testId,
}: {
distance: string | null;
ascent: string | null;
/** Prefix of the badges' test ids: `<testId>-distance`, `<testId>-ascent`. */
testId: string;
}) {
const { t } = useTranslation();
return (
<>
{distance !== null && (
<TourDayRowPill
icon={Ruler}
value={distance}
label={`${t('tours.detail.distance')}: ${distance}`}
testId={`${testId}-distance`}
/>
)}
{ascent !== null && (
<TourDayRowPill
icon={ArrowUp}
value={ascent}
label={`${t('tours.detail.ascent')}: ${ascent}`}
testId={`${testId}-ascent`}
/>
)}
</>
);
}
/**
* What the sums rest on, each as a figure badge: how many tours count with
* their planned values and how many with a recorded track. The planned values
* are never passed off as measured, so they always say so.
*/
export function BasisBadges({ byBasis }: { byBasis: Record<TourActivityBasis, number> }) {
const { t } = useTranslation();
return (
<>
{byBasis.plan > 0 && (
<FigureBadge
testId="tour-activity-basis-plan"
label={t('tours.stats.basisPlan')}
value={String(byBasis.plan)}
icon={<CalendarDays size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
info={t('tours.stats.basisPlanHint')}
/>
)}
{byBasis.track > 0 && (
<FigureBadge
testId="tour-activity-basis-track"
label={t('tours.stats.basisTrack')}
value={String(byBasis.track)}
icon={<Footprints size={10} strokeWidth={2.2} className="flex-none text-success" />}
info={t('tours.stats.basisTrackCountHint')}
/>
)}
</>
);
}
/** What does not count, said as quiet figure badges: tours still ahead, on no day, on days without a date. */
export function UncountedBadges({
upcoming,
unassigned,
undated,
}: {
upcoming: number;
unassigned: number;
undated: number;
}) {
const { t } = useTranslation();
const badges = [
{ key: 'upcoming', value: upcoming, labelKey: 'tours.stats.upcoming', infoKey: 'tours.stats.upcomingHint' },
{
key: 'unassigned',
value: unassigned,
labelKey: 'tours.stats.unassigned',
infoKey: 'tours.stats.unassignedHint',
},
{ key: 'undated', value: undated, labelKey: 'tours.stats.undated', infoKey: 'tours.stats.undatedHint' },
];
return (
<>
{badges
.filter((badge) => badge.value > 0)
.map((badge) => (
<FigureBadge
key={badge.key}
testId={`tour-activity-${badge.key}`}
label={t(badge.labelKey)}
value={String(badge.value)}
icon={<CalendarClock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
info={t(badge.infoKey)}
/>
))}
</>
);
}
/**
* A tour's type as a round tile; a tour done with a recorded track carries
* the footprints at its corner, the way a waypoint's number carries its kind.
*/
export function EntryTile({ entry }: { entry: Pick<TourActivityStatsEntry, 'tour_type' | 'basis'> }) {
const { t } = useTranslation();
return (
<Tooltip label={t(`tourTypes.${entry.tour_type}`)}>
<span className="relative flex-none" aria-label={t(`tourTypes.${entry.tour_type}`)}>
<span className="grid h-[34px] w-[34px] place-items-center rounded-full bg-edge text-content-secondary">
{createElement(tourDayRowIcon(entry.tour_type), { size: 15, strokeWidth: 1.9 })}
</span>
{entry.basis === 'track' && (
<span
aria-hidden="true"
data-testid="tour-activity-track-corner"
className="absolute -bottom-1 -end-1 grid h-4 w-4 place-items-center rounded-full bg-success-soft text-success ring-2 ring-surface-card"
>
<Footprints size={9} strokeWidth={2.6} />
</span>
)}
</span>
</Tooltip>
);
}
/**
* Where the read stands while there is nothing to show yet: on its way, refused
* offline, or failed with a way to try again.
*/
export function ActivityStatusLine({ stats }: { stats: Pick<TourActivityStats, 'status' | 'retry'> }) {
const { t } = useTranslation();
// Not asked yet: the tab has not been shown, so there is nothing to say.
if (stats.status === 'idle') return null;
if (stats.status === 'offline')
return (
<p className="m-0 flex items-center gap-2 text-content-muted" style={fs(12, 'body')}>
<WifiOff size={13} strokeWidth={2} className="flex-none" />
{t('common.needsConnection')}
</p>
);
if (stats.status === 'error')
return (
<p role="alert" className="m-0 flex flex-wrap items-center gap-2 text-content-muted" style={fs(12, 'body')}>
<AlertTriangle size={13} strokeWidth={2} className="flex-none text-warning" />
{t('tours.stats.unavailable')}
<button
type="button"
onClick={stats.retry}
className="font-semibold text-content underline underline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
{t('common.errorRetry')}
</button>
</p>
);
return (
<p role="status" className="m-0 flex items-center gap-2 text-content-faint" style={fs(12, 'body')}>
<Loader2 size={13} strokeWidth={2.2} className="flex-none animate-spin" />
{t('common.loading')}
</p>
);
}
@@ -0,0 +1,39 @@
import type { TourActivityTotals } from '@trek/shared';
import { Layers, type LucideIcon } from 'lucide-react';
import { useSettingsStore } from '../../../store/settingsStore';
import type { DistanceUnit } from '../../../types';
import { formatDistance, formatElevation } from '../../../utils/units';
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
import { sumReading, type ActivityType } from './tourActivityStatsModel';
export type Translate = (key: string, params?: Record<string, unknown>) => string;
/** The distance unit the user reads in. */
export function useDistanceUnit(): DistanceUnit {
return useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
}
/** What a type is called in the filter. */
export function typeLabel(type: ActivityType, t: Translate): string {
return type === 'all' ? t('tours.stats.allTypes') : t(`tourTypes.${type}`);
}
/** The icon a type is drawn with; every type at once is a stack. */
export function typeIcon(type: ActivityType): LucideIcon {
return type === 'all' ? Layers : tourDayRowIcon(type);
}
/** A distance as said, at least that much when some tours do not know theirs; null when none does. */
export function distanceReading(totals: Pick<TourActivityTotals, 'distanceKm'>, unit: DistanceUnit) {
return sumReading(totals.distanceKm, (km) => formatDistance(km, unit));
}
/** A height sum as said, the same way. */
export function heightReading(sum: TourActivityTotals['ascentM'], unit: DistanceUnit) {
return sumReading(sum, (metres) => formatElevation(metres, unit));
}
/** How a short sum explains itself: known for some of the tours only. */
export function partialHint(sum: TourActivityTotals['distanceKm'], t: Translate): string {
return t('tours.scope.partialBody', { known: sum.known, of: sum.of });
}
@@ -0,0 +1,97 @@
// FE-STA2-MODEL-001 to FE-STA2-MODEL-007
// STA-02: the periods and types the Atlas offers for the user's tour
// activities, and how a sum reads when some tours do not know the value.
import type { TourActivityStatsEntry } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
periodOptions,
periodRange,
periodYear,
splitMeasure,
sumReading,
typeOptions,
} from './tourActivityStatsModel';
const TODAY = '2026-10-20';
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
derived_from_place_id: null,
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 10,
ascent_m: 500,
descent_m: 400,
excluded: false,
...overrides,
};
}
describe('tourActivityStatsModel', () => {
it('FE-STA2-MODEL-001: each period covers its days, both ends included', () => {
expect(periodRange('all', TODAY)).toEqual({ from: null, to: null });
expect(periodRange('thisYear', TODAY)).toEqual({ from: '2026-01-01', to: '2026-12-31' });
expect(periodRange('last12', TODAY)).toEqual({ from: '2025-10-20', to: TODAY });
expect(periodRange('year:2024', TODAY)).toEqual({ from: '2024-01-01', to: '2024-12-31' });
});
it('FE-STA2-MODEL-002: offers the earlier years a done tour is in, the newest first, and no empty one', () => {
const entries = [
entry({ date: '2023-05-01' }),
entry({ date: '2025-08-01' }),
entry({ date: '2025-09-01' }),
entry({ date: '2026-03-01' }),
];
expect(periodOptions(entries, TODAY)).toEqual(['all', 'thisYear', 'last12', 'year:2025', 'year:2023']);
expect(periodOptions([], TODAY)).toEqual(['all', 'thisYear', 'last12']);
});
it('FE-STA2-MODEL-003: offers the types of done tours only, in planning order, older types after them', () => {
const entries = [
entry({ tour_type: 'kayak' }),
entry({ tour_type: 'run' }),
entry({ tour_type: 'hike' }),
entry({ tour_type: 'bike', date: '2026-12-01' }),
];
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'run', 'kayak']);
});
it('FE-STA2-MODEL-004: reads the year of a year period only', () => {
expect(periodYear('year:2024')).toBe(2024);
expect(periodYear('thisYear')).toBeNull();
});
it('FE-STA2-MODEL-005: a sum nobody knows says nothing, a short one says at least', () => {
const km = (value: number) => `${value} km`;
expect(sumReading({ value: null, known: 0, of: 2 }, km)).toBeNull();
expect(sumReading({ value: 12, known: 2, of: 2 }, km)).toEqual({ text: '12 km', partial: false });
expect(sumReading({ value: 12, known: 1, of: 2 }, km)).toEqual({ text: '≥ 12 km', partial: true });
});
it('FE-STA2-MODEL-006: splits a measure into its number and unit', () => {
expect(splitMeasure('128.4 km')).toEqual({ value: '128.4', unit: 'km' });
expect(splitMeasure('≥ 12 km')).toEqual({ value: '≥ 12', unit: 'km' });
expect(splitMeasure('12')).toEqual({ value: '12', unit: '' });
});
it('FE-STA2-MODEL-007: a tour still ahead offers neither its year nor its type', () => {
expect(periodOptions([entry({ date: '2025-01-01' }), entry({ date: '2027-01-01' })], TODAY)).toEqual([
'all',
'thisYear',
'last12',
'year:2025',
]);
expect(typeOptions([entry({ tour_type: 'walk', date: TODAY })], TODAY)).toEqual(['all']);
});
it('FE-STA2-MODEL-008: a planned ski tour is a type of its own, after the types planned before it', () => {
const entries = [entry({ tour_type: 'ski' }), entry({ tour_type: 'horse' }), entry({ tour_type: 'hike' })];
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'horse', 'ski']);
});
});
@@ -0,0 +1,90 @@
import {
isTourActivityDone,
plannableTourTypeSchema,
type TourActivityStatsEntry,
type TourActivitySum,
type TourTypeKey,
} from '@trek/shared';
/**
* STA-02: the periods and types the Atlas narrows the user's tour activities
* by, and how a sum reads. Pure, so the hook and its views share one answer.
*/
/** All time, the calendar year, the last twelve months, or one earlier year. */
export type ActivityPeriod = 'all' | 'thisYear' | 'last12' | `year:${number}`;
/** One type, or every type at once. */
export type ActivityType = TourTypeKey | 'all';
interface PeriodRange {
from: string | null;
to: string | null;
}
/** The days a period covers, both included, for `today` (YYYY-MM-DD in the user's own calendar). */
export function periodRange(period: ActivityPeriod, today: string): PeriodRange {
const year = Number(today.slice(0, 4));
if (period === 'thisYear') return { from: `${year}-01-01`, to: `${year}-12-31` };
// A year back to the day, today included: the same day a year ago is the first one.
if (period === 'last12') return { from: `${year - 1}${today.slice(4)}`, to: today };
if (period.startsWith('year:')) {
const chosen = period.slice(5);
return { from: `${chosen}-01-01`, to: `${chosen}-12-31` };
}
return { from: null, to: null };
}
/**
* The periods worth offering: all time, this year and the last twelve months,
* then each earlier year a done tour is in, the newest first. A year without
* a tour would only ever show nothing.
*/
export function periodOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityPeriod[] {
const thisYear = today.slice(0, 4);
const years = new Set(
entries
.filter((entry) => isTourActivityDone(entry, today))
.map((entry) => entry.date.slice(0, 4))
.filter((year) => year < thisYear)
);
const earlier = [...years].sort((a, b) => b.localeCompare(a)).map((year): ActivityPeriod => `year:${Number(year)}`);
return ['all', 'thisYear', 'last12', ...earlier];
}
/** The types a done tour has, in the order tours are planned in, the older types after them. */
export function typeOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityType[] {
const present = new Set(entries.filter((entry) => isTourActivityDone(entry, today)).map((entry) => entry.tour_type));
const planned: readonly string[] = plannableTourTypeSchema.options;
const ordered = [...present].sort((a, b) => rank(planned, a) - rank(planned, b) || a.localeCompare(b));
return ['all', ...ordered];
}
function rank(order: readonly string[], value: string): number {
const index = order.indexOf(value);
return index === -1 ? order.length : index;
}
/** The year of a `year:` period, or null for the others. */
export function periodYear(period: ActivityPeriod): number | null {
return period.startsWith('year:') ? Number(period.slice(5)) : null;
}
/**
* A sum as it is said: nothing when nobody knows the value, else the value,
* marked as at least that much when some of the tours lack it.
*/
export function sumReading(
sum: TourActivitySum,
format: (value: number) => string
): { text: string; partial: boolean } | null {
if (sum.value === null) return null;
const partial = sum.known < sum.of;
return { text: partial ? `≥ ${format(sum.value)}` : format(sum.value), partial };
}
/** A formatted measure split into its number and its unit, so the number can stand large ("128.4", "km"). */
export function splitMeasure(text: string): { value: string; unit: string } {
const at = text.lastIndexOf(' ');
return at === -1 ? { value: text, unit: '' } : { value: text.slice(0, at), unit: text.slice(at + 1) };
}
@@ -0,0 +1,226 @@
// FE-STA2-HOOK-001 to FE-STA2-HOOK-009
// STA-02: the user's own tour activities are read when the tab is first shown,
// narrowed by period and type, and a tour left out shows at once and comes back
// with a message when the server refuses.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { TourActivityStatsEntry, TourActivityStatsResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useTourActivityStats } from './useTourActivityStats';
const repo = vi.hoisted(() => ({ get: vi.fn(), setExcluded: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../../../repo/tourActivityStatsRepo', () => ({
tourActivityStatsRepo: repo,
TourActivityStatsOfflineError: class TourActivityStatsOfflineError extends Error {},
}));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
return {
place_id: 1,
name: 'Ridge',
tour_type: 'hike',
trip: { id: 5, title: 'Alps' },
derived_from_place_id: null,
date: '2026-07-02',
basis: 'plan',
track: null,
day_count: 1,
distance_km: 10,
ascent_m: 500,
descent_m: 400,
excluded: false,
...overrides,
};
}
const stats: TourActivityStatsResponse = {
entries: [
entry(),
entry({ place_id: 2, name: 'Lake', tour_type: 'kayak', date: '2025-08-01', distance_km: 8 }),
entry({ place_id: 3, name: 'Summit', date: '2026-11-01' }),
],
uncounted: { unassigned: 0, undated: 0 },
};
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 9, 20, 12));
repo.get.mockResolvedValue(stats);
});
afterEach(() => vi.useRealTimers());
describe('useTourActivityStats', () => {
it('FE-STA2-HOOK-001: reads nothing until the tab is shown, then sums what is done', async () => {
const { result, rerender } = renderHook(({ active }) => useTourActivityStats(active), {
initialProps: { active: false },
});
expect(result.current.status).toBe('idle');
expect(repo.get).not.toHaveBeenCalled();
rerender({ active: true });
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.summary?.totals.count).toBe(2);
expect(result.current.summary?.totals.distanceKm.value).toBe(18);
expect(result.current.summary?.upcoming).toBe(1);
expect(result.current.periods).toEqual(['all', 'thisYear', 'last12', 'year:2025']);
expect(result.current.types).toEqual(['all', 'hike', 'kayak']);
});
it('FE-STA2-HOOK-002: narrows by period and by type', async () => {
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
act(() => result.current.setPeriod('thisYear'));
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([1]);
act(() => {
result.current.setPeriod('all');
result.current.setType('kayak');
});
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([2]);
});
it('FE-STA2-HOOK-003: offline and failed reads are told apart, and a retry reads again', async () => {
const { TourActivityStatsOfflineError } = await import('../../../repo/tourActivityStatsRepo');
repo.get.mockRejectedValueOnce(new TourActivityStatsOfflineError());
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('offline'));
repo.get.mockRejectedValueOnce(new Error('boom'));
act(() => result.current.retry());
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.status).toBe('error'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(repo.get).toHaveBeenCalledTimes(3);
});
it('FE-STA2-HOOK-004: leaving a tour out shows at once and takes the server answer', async () => {
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
repo.setExcluded.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
let write: Promise<void> = Promise.resolve();
act(() => {
write = result.current.setExcluded(entry(), true);
});
expect(result.current.pending.has(1)).toBe(true);
expect(result.current.summary?.totals.count).toBe(1);
expect(repo.setExcluded).toHaveBeenCalledWith({ place_id: 1, excluded: true });
await act(async () => {
const entries = stats.entries.map((item) => (item.place_id === 1 ? { ...item, excluded: true } : item));
answer({ ...stats, entries });
await write;
});
expect(result.current.pending.size).toBe(0);
expect(result.current.summary?.done.find((item) => item.place_id === 1)?.excluded).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-STA2-HOOK-005: a refused exclusion counts the tour again and says so', async () => {
repo.setExcluded.mockRejectedValue(new Error('nope'));
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
await act(async () => {
await result.current.setExcluded(entry(), true);
});
expect(result.current.summary?.totals.count).toBe(2);
expect(result.current.pending.size).toBe(0);
expect(toast.error).toHaveBeenCalledWith('tours.stats.excludeFailed');
});
it('FE-STA2-HOOK-006: a read answered after the tab went away is dropped', async () => {
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
repo.get.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const { result, unmount } = renderHook(() => useTourActivityStats(true));
expect(result.current.status).toBe('loading');
unmount();
await act(async () => answer(stats));
expect(result.current.data).toBeNull();
});
it('FE-STA2-HOOK-007: a write before the first read leaves the empty state empty', async () => {
repo.get.mockReturnValue(new Promise(() => undefined));
repo.setExcluded.mockRejectedValue(new Error('offline'));
const { result } = renderHook(() => useTourActivityStats(true));
await act(async () => {
await result.current.setExcluded(entry(), true);
});
expect(result.current.data).toBeNull();
expect(toast.error).toHaveBeenCalledTimes(1);
});
it('FE-STA2-HOOK-008: an older exclusion answered last never puts a newer one back', async () => {
const answers: Array<(value: TourActivityStatsResponse) => void> = [];
repo.setExcluded.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
const excludedOf = (placeId: number) =>
result.current.data?.entries.find((item) => item.place_id === placeId)?.excluded;
const withExcluded = (...ids: number[]) => ({
...stats,
entries: stats.entries.map((item) => ({ ...item, excluded: ids.includes(item.place_id) })),
});
let first: Promise<void> = Promise.resolve();
let second: Promise<void> = Promise.resolve();
act(() => {
first = result.current.setExcluded(entry(), true);
});
act(() => {
second = result.current.setExcluded(stats.entries[1] as TourActivityStatsEntry, true);
});
// The second answer comes first and carries both; the first, from before the second, only its own.
await act(async () => {
answers[1]?.(withExcluded(1, 2));
await second;
});
await act(async () => {
answers[0]?.(withExcluded(1));
await first;
});
expect(excludedOf(1)).toBe(true);
expect(excludedOf(2)).toBe(true);
// The other way round: the first comes first, without the second still on its way, which stays left out.
act(() => {
first = result.current.setExcluded(entry(), false);
});
act(() => {
second = result.current.setExcluded(stats.entries[1] as TourActivityStatsEntry, false);
});
await act(async () => {
answers[2]?.(withExcluded(2));
await first;
});
expect(excludedOf(1)).toBe(false);
expect(excludedOf(2)).toBe(false);
await act(async () => {
answers[3]?.(withExcluded());
await second;
});
expect(excludedOf(2)).toBe(false);
});
it('FE-STA2-HOOK-009: a read asked before an exclusion and answered after it does not undo it', async () => {
const { result } = renderHook(() => useTourActivityStats(true));
await waitFor(() => expect(result.current.status).toBe('ready'));
let read: (value: TourActivityStatsResponse) => void = () => undefined;
repo.get.mockReturnValue(new Promise((resolve) => (read = resolve)));
act(() => result.current.retry());
repo.setExcluded.mockResolvedValue({
...stats,
entries: stats.entries.map((item) => (item.place_id === 1 ? { ...item, excluded: true } : item)),
});
await act(async () => {
await result.current.setExcluded(entry(), true);
});
await act(async () => read(stats));
expect(result.current.status).toBe('ready');
expect(result.current.data?.entries.find((item) => item.place_id === 1)?.excluded).toBe(true);
});
});
@@ -0,0 +1,165 @@
import {
summarizeTourActivities,
type TourActivityStatsEntry,
type TourActivityStatsResponse,
type TourActivitySummary,
} from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { TourActivityStatsOfflineError, tourActivityStatsRepo } from '../../../repo/tourActivityStatsRepo';
import { localIsoDate } from '../../../utils/localDate';
import { useToast } from '../../shared/Toast';
import {
periodOptions,
periodRange,
typeOptions,
type ActivityPeriod,
type ActivityType,
} from './tourActivityStatsModel';
/** Where the read stands: not asked yet, on its way, there, refused offline, or failed. */
type ActivityStatsStatus = 'idle' | 'loading' | 'ready' | 'offline' | 'error';
export interface TourActivityStats {
status: ActivityStatsStatus;
data: TourActivityStatsResponse | null;
/** The sums of the chosen period and type; null until the first read is there. */
summary: TourActivitySummary | null;
period: ActivityPeriod;
setPeriod: (period: ActivityPeriod) => void;
periods: ActivityPeriod[];
type: ActivityType;
setType: (type: ActivityType) => void;
types: ActivityType[];
/** The tours whose exclusion is on its way to the server. */
pending: ReadonlySet<number>;
setExcluded: (entry: TourActivityStatsEntry, excluded: boolean) => Promise<void>;
retry: () => void;
}
/** What the latest read came back with, and the attempt it answered. */
interface Answered {
attempt: number;
status: Exclude<ActivityStatsStatus, 'idle' | 'loading'>;
}
/** Every entry of the tour with the flag as asked, the rest as they were. */
function flagged(data: TourActivityStatsResponse, placeId: number, excluded: boolean): TourActivityStatsResponse {
return {
...data,
entries: data.entries.map((entry) => (entry.place_id === placeId ? { ...entry, excluded } : entry)),
};
}
/** Every entry with the flags of the exclusions still on their way laid over it, the way they show already. */
function withFlags(data: TourActivityStatsResponse, flags: ReadonlyMap<number, boolean>): TourActivityStatsResponse {
if (flags.size === 0) return data;
return {
...data,
entries: data.entries.map((entry) =>
flags.has(entry.place_id) ? { ...entry, excluded: flags.get(entry.place_id) === true } : entry
),
};
}
/**
* STA-02: the user's own tour activities in the Atlas. Read live when the tab
* is first shown (and again on retry), never cached on the device; narrowed by
* period and type here, summed by the shared summary the assistant uses too.
* Leaving a tour out shows at once and is undone with a message when the
* server refuses it; the plan of the tour never changes. Answers can come
* back in any order: only the answer to the latest read or write is taken
* whole, with the exclusions still on their way laid over it; an older one
* only confirms its own tour's flag, so it never puts back a state from
* before a newer change.
*/
export function useTourActivityStats(active: boolean): TourActivityStats {
const { t } = useTranslation();
const toast = useToast();
// The answer to the latest read and which attempt it answered: a retry is loading until its own answer is there.
const [answered, setAnswered] = useState<Answered | null>(null);
const [data, setData] = useState<TourActivityStatsResponse | null>(null);
const [period, setPeriod] = useState<ActivityPeriod>('all');
const [type, setType] = useState<ActivityType>('all');
const [pending, setPending] = useState<ReadonlySet<number>>(() => new Set());
const [attempt, setAttempt] = useState(0);
const today = localIsoDate();
// Raised by every read and every write, so an answer knows whether a newer request went out after it.
const latest = useRef(0);
// The exclusions on their way to the server, by tour: the flag the user asked for.
const inFlight = useRef(new Map<number, boolean>());
useEffect(() => {
if (!active) return;
const controller = new AbortController();
const asked = ++latest.current;
tourActivityStatsRepo.get(controller.signal).then(
(answer) => {
if (controller.signal.aborted) return;
if (asked === latest.current) setData(withFlags(answer, inFlight.current));
setAnswered({ attempt, status: 'ready' });
},
(err: unknown) => {
if (controller.signal.aborted) return;
setAnswered({ attempt, status: err instanceof TourActivityStatsOfflineError ? 'offline' : 'error' });
}
);
return () => controller.abort();
}, [active, attempt]);
let status: ActivityStatsStatus = 'loading';
if (answered?.attempt === attempt) status = answered.status;
else if (!active && !answered) status = 'idle';
const summary = useMemo(() => {
if (!data) return null;
const range = periodRange(period, today);
return summarizeTourActivities(data.entries, { today, ...range, type: type === 'all' ? null : type });
}, [data, period, type, today]);
const periods = useMemo(() => periodOptions(data?.entries ?? [], today), [data, today]);
const types = useMemo(() => typeOptions(data?.entries ?? [], today), [data, today]);
const setExcluded = useCallback(
async (entry: TourActivityStatsEntry, excluded: boolean) => {
const placeId = entry.place_id;
const asked = ++latest.current;
inFlight.current.set(placeId, excluded);
setPending((current) => new Set(current).add(placeId));
setData((current) => (current ? flagged(current, placeId, excluded) : current));
try {
const answer = await tourActivityStatsRepo.setExcluded({ place_id: placeId, excluded });
inFlight.current.delete(placeId);
if (asked === latest.current) setData(withFlags(answer, inFlight.current));
else setData((current) => (current ? flagged(current, placeId, excluded) : current));
} catch {
inFlight.current.delete(placeId);
setData((current) => (current ? flagged(current, placeId, !excluded) : current));
toast.error(t('tours.stats.excludeFailed'));
} finally {
setPending((current) => {
const next = new Set(current);
next.delete(placeId);
return next;
});
}
},
[t, toast]
);
const retry = useCallback(() => setAttempt((count) => count + 1), []);
return {
status,
data,
summary,
period,
setPeriod,
periods,
type,
setType,
types,
pending,
setExcluded,
retry,
};
}
@@ -1,4 +1,4 @@
// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-014 // FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-019
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import type { DawarichSuggestion } from '@trek/shared' import type { DawarichSuggestion } from '@trek/shared'
import { import {
@@ -7,6 +7,7 @@ import {
formatDayOption, formatDayOption,
formatDuration, formatDuration,
groupByDay, groupByDay,
openStayDatesWithin,
openStaysByDate, openStaysByDate,
timeRange, timeRange,
} from './dawarichSuggestionModel' } from './dawarichSuggestionModel'
@@ -110,6 +111,11 @@ describe('clockOf / timeRange', () => {
expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense', false)).toBe('10:15') expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense', false)).toBe('10:15')
}) })
it('FE-DAWARICH-SUGMODEL-020: a stay into the next day carries the days it ran over', () => {
expect(timeRange('2026-10-24T18:30:00+02:00', '2026-10-25T08:30:00+02:00', false)).toBe('18:30 – 08:30 +1')
expect(timeRange('2026-10-24T18:30:00+02:00', '2026-10-26T08:30:00+02:00', false)).toBe('18:30 – 08:30 +2')
})
it('FE-DAWARICH-SUGMODEL-007: no arrival, no range', () => { it('FE-DAWARICH-SUGMODEL-007: no arrival, no range', () => {
expect(timeRange('nonsense', '2026-09-10T12:40:00+02:00', false)).toBe('') expect(timeRange('nonsense', '2026-09-10T12:40:00+02:00', false)).toBe('')
}) })
@@ -212,4 +218,20 @@ describe('openStaysByDate', () => {
expect(byDate.size).toBe(0) expect(byDate.size).toBe(0)
}) })
it('FE-DAWARICH-SUGMODEL-019: the waiting days inside a journal span, oldest first, and none without a span', () => {
// The stays are the whole account's; a day outside the journal's entries and trips is
// not one it can take over.
const byDate = openStaysByDate([
stay({ id: 1, localDate: '2026-09-12' }),
stay({ id: 2, localDate: '2026-09-10' }),
stay({ id: 3, localDate: '2026-12-24' }),
])
expect(openStayDatesWithin(byDate, ['2026-09-11', null, '2026-09-09', undefined, '2026-09-14'])).toEqual([
'2026-09-10',
'2026-09-12',
])
expect(openStayDatesWithin(byDate, [null, undefined])).toEqual([])
})
}) })
@@ -71,6 +71,22 @@ export function openStaysByDate(suggestions: DawarichSuggestion[]): Map<string,
return byDate; return byDate;
} }
/**
* The days with waiting stays that fall inside a journal's span, oldest first. The span
* is whatever dates bound it (its entries, its trips' first and last days); without any
* there is nothing to place a stay against, because the stays are the whole account's.
*/
export function openStayDatesWithin(
byDate: Map<string, DawarichSuggestion[]>,
span: Array<string | null | undefined>
): string[] {
const bounds = span.filter((date): date is string => !!date).sort((a, b) => a.localeCompare(b));
const first = bounds[0];
const last = bounds[bounds.length - 1];
if (!first || !last) return [];
return [...byDate.keys()].filter((date) => date >= first && date <= last).sort((a, b) => a.localeCompare(b));
}
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */ /** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
export function clockOf(iso: string): string { export function clockOf(iso: string): string {
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : '' return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
@@ -86,12 +102,17 @@ export function clockOf(iso: string): string {
* timestamp and printed, so a traveller on a 12-hour clock read every Dawarich time in * timestamp and printed, so a traveller on a 12-hour clock read every Dawarich time in
* 24-hour while the rest of the app obeyed the setting. A required argument makes the * 24-hour while the rest of the app obeyed the setting. A required argument makes the
* next caller answer the question rather than inherit the bug. * next caller answer the question rather than inherit the bug.
*
* A stay that ends on a later day says so, `08:30 +1` after an evening arrival, as a timetable
* does, or a night at the hotel reads as fourteen hours that never happened.
*/ */
export function timeRange(startedAt: string, endedAt: string, is12h: boolean): string { export function timeRange(startedAt: string, endedAt: string, is12h: boolean): string {
const from = formatClockTime(clockOf(startedAt), is12h) const from = formatClockTime(clockOf(startedAt), is12h)
const to = formatClockTime(clockOf(endedAt), is12h) const to = formatClockTime(clockOf(endedAt), is12h)
if (!from) return '' if (!from) return ''
return to ? `${from} – ${to}` : from if (!to) return from
const days = Math.round((Date.parse(endedAt.slice(0, 10)) - Date.parse(startedAt.slice(0, 10))) / 86_400_000)
return days > 0 ? `${from} – ${to} +${days}` : `${from} – ${to}`
} }
/** /**
@@ -0,0 +1,62 @@
import { useId, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import DawarichIcon from '../shared/DawarichIcon';
import { DialogFooter, DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../shared/DialogShell';
/**
* The desktop frame of a journey day read from Dawarich (JRN-01, JRN-02): the
* Dawarich mark, the journey and the day in the head band, what the dialog
* does under them, the body and the buttons.
*/
export default function DawarichDayDialog({
journeyTitle,
date,
sub,
onClose,
footer,
children,
}: {
journeyTitle: string;
date: string;
sub: string;
onClose: () => void;
footer: ReactNode;
children: ReactNode;
}) {
const { locale } = useTranslation();
const labelId = useId();
const title = new Date(`${date}T00:00:00`).toLocaleDateString(locale, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="detail"
align="top"
header={
<DialogHeader
tile={
<DialogTile>
<DawarichIcon size={26} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={journeyTitle}
title={title}
sub={sub}
subWraps
/>
}
footer={<DialogFooter>{footer}</DialogFooter>}
>
{children}
</DialogShell>
);
}
@@ -1,4 +1,4 @@
// FE-JRN-DAYDAW-001 to FE-JRN-DAYDAW-008 // FE-JRN-DAYDAW-001 to FE-JRN-DAYDAW-010
/** /**
* The fold a journal day carries for the stays Dawarich recorded on it. * The fold a journal day carries for the stays Dawarich recorded on it.
* *
@@ -144,4 +144,27 @@ describe('JourneyDayDawarich', () => {
expect(screen.getByText('8:00 AM – 5:08 PM')).toBeInTheDocument() expect(screen.getByText('8:00 AM – 5:08 PM')).toBeInTheDocument()
useSettingsStore.setState(s => ({ settings: { ...s.settings, time_format: '24h' } })) useSettingsStore.setState(s => ({ settings: { ...s.settings, time_format: '24h' } }))
}) })
it('FE-JRN-DAYDAW-009: beside the shut row, the whole day can be taken over at once (JRN-02)', () => {
const onTakeOverDay = vi.fn()
render(
<JourneyDayDawarich
suggestions={[stay({ id: 1 })]}
busyId={null}
onAccept={vi.fn()}
onDismiss={vi.fn()}
onTakeOverDay={onTakeOverDay}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Take over day' }))
expect(onTakeOverDay).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { expanded: false })).toBeInTheDocument()
})
it('FE-JRN-DAYDAW-010: without the right to take a day over there is no such button', () => {
fold([stay({ id: 1 })])
expect(screen.queryByRole('button', { name: 'Take over day' })).not.toBeInTheDocument()
})
}) })
@@ -1,8 +1,9 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react' import { ChevronDown, ChevronRight, ListChecks } from 'lucide-react'
import type { DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared' import type { DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import DawarichIcon from '../shared/DawarichIcon' import DawarichIcon from '../shared/DawarichIcon'
import { fs } from '../shared/DialogShell'
import { SuggestionRow } from '../Dawarich/DawarichSuggestionsPanel' import { SuggestionRow } from '../Dawarich/DawarichSuggestionsPanel'
import { timeRange } from '../Dawarich/dawarichSuggestionModel' import { timeRange } from '../Dawarich/dawarichSuggestionModel'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
@@ -28,6 +29,7 @@ export default function JourneyDayDawarich({
busyId, busyId,
onAccept, onAccept,
onDismiss, onDismiss,
onTakeOverDay,
}: { }: {
/** The open stays of this day, in the order they were lived. */ /** The open stays of this day, in the order they were lived. */
suggestions: DawarichSuggestion[] suggestions: DawarichSuggestion[]
@@ -35,6 +37,8 @@ export default function JourneyDayDawarich({
busyId: number | null busyId: number | null
onAccept: (suggestion: DawarichSuggestion, target: DawarichSuggestionTarget) => void onAccept: (suggestion: DawarichSuggestion, target: DawarichSuggestionTarget) => void
onDismiss: (suggestion: DawarichSuggestion) => void onDismiss: (suggestion: DawarichSuggestion) => void
/** Review the whole day with its stays and route instead of one stay at a time (JRN-02). */
onTakeOverDay?: () => void
}): React.ReactElement | null { }): React.ReactElement | null {
const { t } = useTranslation() const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
@@ -49,11 +53,12 @@ export default function JourneyDayDawarich({
return ( return (
<div className="rounded-2xl border border-edge bg-surface-secondary overflow-hidden"> <div className="rounded-2xl border border-edge bg-surface-secondary overflow-hidden">
<div className="flex items-center">
<button <button
type="button" type="button"
onClick={() => setOpen(v => !v)} onClick={() => setOpen(v => !v)}
aria-expanded={open} aria-expanded={open}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-start transition-colors hover:bg-surface-hover" className="flex min-w-0 flex-1 items-center gap-2.5 px-3 py-2.5 text-start transition-colors hover:bg-surface-hover"
> >
{/* The brand mark in its own colours, which is what tells this row from an entry {/* The brand mark in its own colours, which is what tells this row from an entry
at a glance. No second label saying "Dawarich": the mark is the label. */} at a glance. No second label saying "Dawarich": the mark is the label. */}
@@ -68,6 +73,20 @@ export default function JourneyDayDawarich({
? <ChevronDown size={16} className="shrink-0 text-content-faint" aria-hidden /> ? <ChevronDown size={16} className="shrink-0 text-content-faint" aria-hidden />
: <ChevronRight size={16} className="shrink-0 text-content-faint" aria-hidden />} : <ChevronRight size={16} className="shrink-0 text-content-faint" aria-hidden />}
</button> </button>
{/* The whole day at once, beside the row: choosing, joining and naming
its stays together beats accepting them one by one. */}
{onTakeOverDay && (
<button
type="button"
onClick={onTakeOverDay}
className="me-2 inline-flex shrink-0 items-center gap-1.5 rounded-lg bg-accent px-2.5 py-1.5 font-semibold text-accent-text transition-opacity hover:opacity-90"
style={fs(12, 'body')}
>
<ListChecks size={14} aria-hidden />
{t('journey.dayImport.open')}
</button>
)}
</div>
{open && ( {open && (
<div className="flex flex-col gap-1 pb-2"> <div className="flex flex-col gap-1 pb-2">
@@ -0,0 +1,68 @@
// FE-JRN-DAY-IMPORT-BTN-001 to FE-JRN-DAY-IMPORT-BTN-005
//
// JRN-02: the day header's way into taking a day over with its stays.
// JRN-03: the mark of a prepared day that is not final yet.
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import JourneyDayImportButton from './JourneyDayImportButton';
function renderButton(props: Partial<Parameters<typeof JourneyDayImportButton>[0]> = {}) {
const onOpen = vi.fn();
const view = render(
<JourneyDayImportButton pending={0} canTakeOver needsConnection={false} onOpen={onOpen} {...props} />
);
return { onOpen, ...view };
}
describe('JourneyDayImportButton', () => {
it('FE-JRN-DAY-IMPORT-BTN-001: counts the stays still waiting on the day and opens the take-over', async () => {
const { onOpen } = renderButton({ pending: 6 });
const button = screen.getByTestId('journey-day-import');
expect(button).toHaveTextContent('6');
expect(button).toHaveAccessibleName('6 stays to review');
await userEvent.click(button);
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('FE-JRN-DAY-IMPORT-BTN-002: a day with nothing waiting still offers the take-over, without a number', () => {
renderButton();
const button = screen.getByTestId('journey-day-import');
expect(button).toHaveAccessibleName('Take over this day with its stays');
expect(button.textContent).toBe('');
});
it('FE-JRN-DAY-IMPORT-BTN-003: offline it stays dimmed and says why; without Dawarich or the right to edit it is not there', async () => {
const { onOpen, unmount } = renderButton({ canTakeOver: false, needsConnection: true, pending: 2 });
const button = screen.getByTestId('journey-day-import');
expect(button).toHaveAttribute('aria-disabled', 'true');
await userEvent.click(button);
expect(onOpen).not.toHaveBeenCalled();
unmount();
renderButton({ canTakeOver: false, needsConnection: false });
expect(screen.queryByTestId('journey-day-import')).not.toBeInTheDocument();
});
it('FE-JRN-DAY-IMPORT-BTN-004: a prepared day still running carries a dot and says so after the count', () => {
renderButton({ pending: 6, prep: { state: 'running', failed: false, lastSuccessAt: null } });
expect(screen.getByTestId('journey-day-prep-unsettled')).toBeInTheDocument();
expect(screen.getByTestId('journey-day-import')).toHaveAccessibleName(
/^6 stays to review\s+The day is still running, more stays may follow\.$/
);
});
it('FE-JRN-DAY-IMPORT-BTN-005: a day fetched whole carries no dot, one whose refresh failed does', () => {
const { unmount } = renderButton({
prep: { state: 'complete', failed: false, lastSuccessAt: '2026-05-15T08:00:00Z' },
});
expect(screen.queryByTestId('journey-day-prep-unsettled')).not.toBeInTheDocument();
unmount();
renderButton({ prep: { state: 'complete', failed: true, lastSuccessAt: '2026-05-15T08:00:00Z' } });
expect(screen.getByTestId('journey-day-prep-unsettled')).toBeInTheDocument();
expect(screen.getByTestId('journey-day-import')).toHaveAccessibleName(
/The last refresh failed, this is the older state\./
);
});
});
@@ -0,0 +1,68 @@
import { ListChecks } from 'lucide-react';
import type React from 'react';
import { useTranslation } from '../../i18n';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { dayPrepText, dayPrepUnsettled, type JourneyDayPrep } from './journeyRecordingPrepModel';
/**
* Taking a day over with its stays (JRN-02), in the day header beside the
* route: for an editor whose Dawarich answers, with the number of stays still
* waiting on the day; offline it stays, dimmed, and says why. While the
* journey prepares its days (JRN-03), a dot marks a day that is not final yet
* (still running, fetched before it ended, not fetched, or stale after a
* failed refresh) and the tooltip says which.
*/
export default function JourneyDayImportButton({
pending,
canTakeOver,
needsConnection,
prep,
onOpen,
}: {
/** Stays of the day nobody decided on yet. */
pending: number;
canTakeOver: boolean;
needsConnection: boolean;
/** Where preparing the day stands; absent while the journey does not prepare it. */
prep?: JourneyDayPrep | null;
onOpen: () => void;
}): React.ReactElement | null {
const { t, locale } = useTranslation();
if (!canTakeOver && !needsConnection) return null;
const base = !canTakeOver
? t('dawarich.error.offline')
: pending > 0
? t('journey.dayImport.pending', { count: pending })
: t('journey.dayImport.openHint');
const unsettled = canTakeOver && dayPrepUnsettled(prep);
// The tooltip breaks before the second line; a screen reader reads both as one.
const label = unsettled && prep ? `${base}\n${dayPrepText(prep, t, locale)}` : base;
return (
<Tooltip label={label} placement="top">
<button
type="button"
onClick={canTakeOver ? onOpen : undefined}
aria-disabled={!canTakeOver}
aria-label={label}
data-testid="journey-day-import"
className="inline-flex h-7 items-center gap-1 rounded-full px-2 text-content-secondary transition-colors aria-disabled:cursor-default aria-disabled:opacity-40 [&:not([aria-disabled=true])]:hover:bg-surface-hover"
style={{ background: 'var(--vg-surf2)' }}
>
<ListChecks size={14} strokeWidth={2.2} aria-hidden="true" />
{pending > 0 && (
<span className="font-bold tabular-nums" style={fs(10.5)}>
{pending}
</span>
)}
{unsettled && (
<span
data-testid="journey-day-prep-unsettled"
className="h-1.5 w-1.5 rounded-full bg-warning"
aria-hidden="true"
/>
)}
</button>
</Tooltip>
);
}
@@ -0,0 +1,200 @@
// FE-JRN-DAYIMPORT-DIALOG-001 to FE-JRN-DAYIMPORT-DIALOG-008
//
// JRN-02: the desktop dialog that takes a recorded day over with its stays. It
// names the journey and the day, shows the stays on the map and in a list to
// choose, rename and join, what the journal holds on the day already and who
// sees the drafts, and writes only on its button.
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildDayStay, buildDayStays, buildRecordingPreview } from '../../../tests/helpers/journeyRecording';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import JourneyDayImportDialog from './JourneyDayImportDialog';
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep';
const repo = vi.hoisted(() => ({
reviewDay: vi.fn(),
takeOverDay: vi.fn(),
preview: vi.fn(),
save: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const map = vi.hoisted(() => ({ props: {} as Record<string, unknown> }));
vi.mock('./JourneyMapAuto', () => ({
default: (props: Record<string, unknown>) => {
map.props = props;
return <div data-testid="day-map" />;
},
}));
function renderDialog(prep?: JourneyRecordingPrepState) {
const props = { onTakenOver: vi.fn(), onKept: vi.fn(), onClose: vi.fn() };
render(
<JourneyDayImportDialog
journeyId={7}
journeyTitle="Venice"
date="2026-05-14"
dayColor="#ff0055"
storedRoute={undefined}
prep={prep}
{...props}
/>
);
return props;
}
function prepState(over: Partial<JourneyRecordingPrepState> = {}): JourneyRecordingPrepState {
return {
prep: {
enabled: true,
connection: 'ready',
lastAttemptAt: '2026-05-16T08:00:00.000Z',
lastSuccessAt: '2026-05-14T18:00:00.000Z',
lastError: 'unreachable',
},
saving: false,
fetching: false,
statusLine: { text: 'The last refresh failed: Dawarich did not answer.', tone: 'warning' },
canFetch: true,
setEnabled: vi.fn(),
fetchNow: vi.fn(async () => {}),
dayPrep: () => ({ state: 'provisional', failed: true, lastSuccessAt: '2026-05-14T18:00:00.000Z' }),
...over,
};
}
const rowOf = (name: string) =>
screen.getByRole('checkbox', { name: `Take over ${name}` }).closest('[data-stay-id]') as HTMLElement;
beforeEach(() => {
for (const fn of [...Object.values(repo), ...Object.values(toast)]) fn.mockReset();
repo.reviewDay.mockResolvedValue(
buildDayStays({
entries: [{ id: 3, title: 'Breakfast at the hotel', locationName: null, entryTime: '08:00', isDraft: false }],
audience: 2,
})
);
repo.preview.mockResolvedValue(buildRecordingPreview());
repo.takeOverDay.mockResolvedValue({
entries: [{ id: 5, title: 'Piazza San Marco', stayCount: 2 }],
alreadyTaken: [],
skipped: [],
});
});
describe('JourneyDayImportDialog', () => {
it('FE-JRN-DAYIMPORT-DIALOG-001: names the journey and the day, and shows the stays, the route and the day so far', async () => {
renderDialog();
expect(await screen.findByRole('checkbox', { name: 'Take over Parcheggio' })).toBeChecked();
expect(screen.getByText('Venice')).toBeInTheDocument();
expect(screen.getByText(/May 14, 2026/)).toBeInTheDocument();
expect(screen.getByTestId('day-map')).toBeInTheDocument();
expect(map.props.entries).toHaveLength(4);
expect(screen.getByText('Breakfast at the hotel')).toBeInTheDocument();
expect(screen.getByText(/2 other people on this journey see them/)).toBeInTheDocument();
expect(within(rowOf('Parcheggio')).getByText('Unconfirmed')).toBeInTheDocument();
expect(await screen.findByText("Keep the day's route too")).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Take over 3 drafts' })).toBeEnabled();
expect(repo.takeOverDay).not.toHaveBeenCalled();
});
it('FE-JRN-DAYIMPORT-DIALOG-002: the same place twice starts joined under one title, and can be kept apart', async () => {
const user = userEvent.setup();
renderDialog();
await screen.findByRole('checkbox', { name: 'Take over Parcheggio' });
expect(screen.getByRole('textbox', { name: 'Title of the entry' })).toHaveValue('Piazza San Marco');
await user.click(screen.getByRole('button', { name: 'Keep apart' }));
expect(screen.queryByRole('textbox', { name: 'Title of the entry' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Take over 4 drafts' })).toBeInTheDocument();
});
it('FE-JRN-DAYIMPORT-DIALOG-003: a stay left out is dimmed and counted, and the drafts are written on the button', async () => {
const user = userEvent.setup();
const props = renderDialog();
await screen.findByRole('checkbox', { name: 'Take over Parcheggio' });
await user.click(screen.getByRole('checkbox', { name: 'Take over Parcheggio' }));
expect(rowOf('Parcheggio').className).toContain('opacity-60');
expect(screen.getByText('1 left out')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Take over 2 drafts' }));
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
expect(repo.takeOverDay).toHaveBeenCalledWith(
7,
'2026-05-14',
expect.objectContaining({ skipSuggestionIds: [10] })
);
expect(props.onTakenOver).toHaveBeenCalled();
});
it('FE-JRN-DAYIMPORT-DIALOG-004: a day report gathers every chosen stay under one title', async () => {
const user = userEvent.setup();
renderDialog();
await screen.findByRole('checkbox', { name: 'Take over Parcheggio' });
await user.click(screen.getByRole('button', { name: 'Day report' }));
expect(screen.getByRole('textbox', { name: 'Title of the entry' })).toHaveValue('Day of May 14');
expect(screen.queryByRole('button', { name: 'Keep apart' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Take over 1 draft' }));
await waitFor(() => expect(repo.takeOverDay).toHaveBeenCalled());
expect(repo.takeOverDay.mock.calls[0]?.[2]).toMatchObject({
groups: [{ kind: 'report', title: 'Day of May 14', anchorSuggestionId: 12 }],
});
});
it('FE-JRN-DAYIMPORT-DIALOG-005: a stay the journal holds is shown with its entry and cannot be chosen again', async () => {
repo.reviewDay.mockResolvedValue(
buildDayStays({
stays: [buildDayStay({ suggestionId: 11, status: 'here', entry: { id: 4, title: 'Morning in San Marco' } })],
})
);
renderDialog();
const box = await screen.findByRole('checkbox', { name: 'Take over Piazza San Marco' });
expect(box).toBeDisabled();
expect(box).not.toBeChecked();
expect(screen.getByText('In Morning in San Marco')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Take over 0 drafts' })).toBeDisabled();
});
it('FE-JRN-DAYIMPORT-DIALOG-006: when Dawarich does not answer, the stays of the last sync are shown with the reason', async () => {
repo.reviewDay.mockResolvedValue(
buildDayStays({ fresh: false, error: 'unreachable', syncedAt: '2026-05-14T20:00:00Z', truncated: true })
);
renderDialog();
expect(await screen.findByText(/Dawarich did not answer/)).toBeInTheDocument();
expect(screen.getByText(/Some stays may be missing/)).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: 'Take over Rialto' })).toBeChecked();
});
it('FE-JRN-DAYIMPORT-DIALOG-007: an unsettled prepared day says why, with the caller status and a fetch by hand', async () => {
const user = userEvent.setup();
const prep = prepState();
renderDialog(prep);
expect(await screen.findByText(/before the day ended, so stays may be missing/)).toBeInTheDocument();
expect(screen.getByText(/this is the older state/)).toBeInTheDocument();
expect(screen.getByTestId('journey-prep-status')).toHaveTextContent(
'The last refresh failed: Dawarich did not answer.'
);
await user.click(screen.getByRole('button', { name: 'Fetch now' }));
expect(prep.fetchNow).toHaveBeenCalledTimes(1);
});
it('FE-JRN-DAYIMPORT-DIALOG-008: without the journey preparing its days, no status and no day notice', async () => {
renderDialog(prepState({ statusLine: null, canFetch: false, dayPrep: () => null }));
expect(await screen.findByRole('checkbox', { name: 'Take over Parcheggio' })).toBeChecked();
expect(screen.queryByTestId('journey-prep-status')).not.toBeInTheDocument();
expect(screen.queryByText(/this is the older state/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,33 @@
import { useTranslation } from '../../i18n';
import DawarichDayDialog from './DawarichDayDialog';
import { JourneyDayImportActions, JourneyDayImportReview } from './JourneyDayImportReview';
import { useJourneyDayImport, type JourneyDayImportViewProps } from './useJourneyDayImport';
/**
* Taking a recorded day with its stays into the journal (JRN-02), on the
* desktop: the route, the stays and the day's entries in the body, the drafts
* written only on the button that says how many.
*/
export default function JourneyDayImportDialog({
journeyTitle,
date,
dayColor,
onClose,
prep,
...rest
}: JourneyDayImportViewProps) {
const { t } = useTranslation();
const state = useJourneyDayImport({ date, dayColor, onClose, ...rest });
return (
<DawarichDayDialog
journeyTitle={state.review?.journey.title ?? journeyTitle}
date={date}
sub={t('journey.dayImport.sub')}
onClose={onClose}
footer={<JourneyDayImportActions state={state} onCancel={onClose} />}
>
<JourneyDayImportReview state={state} dayColor={dayColor} prep={prep} />
</DawarichDayDialog>
);
}
@@ -0,0 +1,280 @@
import { Clock, FilePen, Loader2, RefreshCw, Route, Users } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatClockTime } from '../../utils/formatters';
import { formatDistance } from '../../utils/units';
import { SoftPill } from '../Planner/planParts';
import ToggleSwitch from '../Settings/ToggleSwitch';
import { Segmented } from '../shared/dialogParts';
import { DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { JourneyDayImportStays } from './JourneyDayImportStays';
import JourneyMap from './JourneyMapAuto';
import { reasonText } from './journeyRecordingModel';
import { dayPrepNotice } from './journeyRecordingPrepModel';
import { JourneyRecordingPrepStatus } from './JourneyRecordingPrepSetting';
import { Notice } from './JourneyRecordingReview';
import { stayMarkerId, type JourneyDayImportState } from './useJourneyDayImport';
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep';
type Props = { state: JourneyDayImportState; dayColor: string; prep?: JourneyRecordingPrepState };
/**
* Why the list may not be the whole day, and what went wrong on the way. With
* the journey preparing its days, why this day is not final yet and how the
* caller's own preparation stands, so an editor sees it without the settings.
*/
function DayNotices({ state, prep }: { state: JourneyDayImportState; prep?: JourneyRecordingPrepState }) {
const { t, locale } = useTranslation();
const { review, outcome } = state;
const reason = review ? reasonText(review.error, t) : '';
const day = prep?.dayPrep(state.date);
const dayNotice = day ? dayPrepNotice(day, t, locale) : '';
return (
<>
{outcome?.kind === 'failed' && (
<Notice tone="danger">{t('journey.dayImport.failed', { reason: outcome.reason })}</Notice>
)}
{outcome?.kind === 'route' && (
<Notice tone="warning">{t('journey.dayImport.routeFailed', { reason: outcome.reason })}</Notice>
)}
{review && !review.fresh && (
<Notice tone="warning">
{review.syncedAt
? t('journey.dayImport.stale', {
reason,
date: new Date(review.syncedAt).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }),
})
: t('journey.dayImport.staleNever', { reason })}
</Notice>
)}
{review?.truncated && <Notice tone="warning">{t('journey.dayImport.truncated')}</Notice>}
{state.running && <Notice tone="info">{t('journey.dayImport.running')}</Notice>}
{dayNotice && <Notice tone="warning">{dayNotice}</Notice>}
{prep && <JourneyRecordingPrepStatus prep={prep} />}
</>
);
}
/** The day's route under the pins: kept already, to keep along, kept in another version, or none recorded. */
function RouteLine({ state }: { state: JourneyDayImportState }) {
const { t } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
const { route } = state;
const distance = route.distanceMeters === null ? null : formatDistance(route.distanceMeters / 1000, unit);
if (route.offer === 'loading') {
return (
<p className="m-0 flex items-center gap-1.5 text-content-faint" style={fs(11.5)}>
<Loader2 size={12} className="animate-spin" aria-hidden="true" />
{t('journey.recording.loading')}
</p>
);
}
if (route.offer === 'keep') {
return (
<div className="flex items-center justify-between gap-3">
<span className="min-w-0">
<span className="block text-content" style={fs(12.5, 'body')}>
{t('journey.dayImport.keepRoute')}
</span>
<span className="block text-content-faint" style={fs(11.5)}>
{t('journey.dayImport.keepRouteHint', { distance: distance ?? '' })}
</span>
</span>
<ToggleSwitch on={route.keep} onToggle={route.toggle} label={t('journey.dayImport.keepRoute')} />
</div>
);
}
if (route.offer === 'none') {
return (
<p className="m-0 text-content-faint" style={fs(11.5)}>
{t('journey.dayImport.routeNone')}
</p>
);
}
return (
<div className="flex flex-wrap items-center gap-1.5">
{distance && <SoftPill icon={<Route size={11} aria-hidden="true" />}>{distance}</SoftPill>}
<SoftPill tone="success">{t('journey.dayImport.routeKept')}</SoftPill>
{route.offer === 'differs' && (
<span className="text-content-faint" style={fs(11.5)}>
{t('journey.dayImport.routeDiffers')}
</span>
)}
</div>
);
}
/** What the journal has on the day already, so nothing is taken over twice by hand. */
function DayEntries({ state }: { state: JourneyDayImportState }) {
const { t } = useTranslation();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const entries = state.review?.entries ?? [];
if (entries.length === 0) return null;
return (
<DialogSection label={t('journey.dayImport.existing')}>
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{entries.map((entry) => (
<li
key={entry.id}
className="flex min-w-0 items-center gap-1.5 text-content-secondary"
style={fs(12.5, 'body')}
>
<span className="truncate">{entry.title || entry.locationName || t('journey.dayImport.untitled')}</span>
{entry.entryTime && (
<SoftPill icon={<Clock size={11} aria-hidden="true" />}>
{formatClockTime(entry.entryTime, is12h)}
</SoftPill>
)}
{entry.isDraft && (
<SoftPill icon={<FilePen size={11} aria-hidden="true" />}>{t('journey.dayImport.draft')}</SoftPill>
)}
</li>
))}
</ul>
</DialogSection>
);
}
function scrollToStay(id: string): void {
document
.querySelector(`[data-stay-id="${id.replace('stay-', '')}"]`)
?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
/**
* Reviewing a recorded day with its stays (JRN-02): the route and the stays on
* one map, the stays as a list to choose, rename and join, what the journal
* holds on the day already, and who will see the drafts. The body of the
* desktop dialog and of the phone sheet alike.
*/
export function JourneyDayImportReview({ state, dayColor, prep }: Props) {
const { t } = useTranslation();
const { review, choice } = state;
if (!review) {
if (state.error) {
return (
<div className="flex flex-col gap-2">
<Notice tone="danger">{state.error}</Notice>
<span>
<DialogButton onClick={state.retry} icon={<RefreshCw size={14} aria-hidden="true" />}>
{t('journey.recording.retry')}
</DialogButton>
</span>
</div>
);
}
return (
<p className="m-0 flex items-center gap-2 py-6 text-content-muted" role="status" style={fs(12.5, 'body')}>
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
{t('journey.dayImport.loading')}
</p>
);
}
return (
<>
<DayNotices state={state} prep={prep} />
<DialogSection label={t('journey.dayImport.map')}>
<div className="flex flex-col gap-2.5">
<div className="overflow-hidden rounded-[12px] border border-edge-faint">
<JourneyMap
checkins={[]}
entries={state.mapEntries}
tracks={state.mapTracks}
height={200}
activeMarkerId={state.activeStay === null ? null : stayMarkerId(state.activeStay)}
onMarkerClick={scrollToStay}
/>
</div>
<RouteLine state={state} />
</div>
</DialogSection>
<DialogSection
label={t('journey.dayImport.stays')}
action={
choice && review.stays.length > 1 ? (
<Segmented
label={t('journey.dayImport.stays')}
value={choice.mode}
onChange={state.setMode}
options={[
{ value: 'entries', label: t('journey.dayImport.modeEntries') },
{ value: 'report', label: t('journey.dayImport.modeReport') },
]}
/>
) : undefined
}
>
{review.stays.length === 0 ? (
<Notice tone="info">{t('journey.dayImport.empty')}</Notice>
) : (
<JourneyDayImportStays state={state} dayColor={dayColor} />
)}
</DialogSection>
<DayEntries state={state} />
<p className="m-0 flex items-start gap-2 text-content-muted" style={fs(12, 'body')}>
<Users size={14} className="mt-px flex-none" aria-hidden="true" />
{review.audience > 0
? t('journey.dayImport.audience', { count: review.audience })
: t('journey.dayImport.draftsNote')}
</p>
</>
);
}
/**
* The buttons under the review: how many stays are left out, close, and the
* one that takes the chosen stays over as drafts, saying why when it cannot
* yet. After a route that was not kept, it offers the route alone again.
*/
export function JourneyDayImportActions({ state, onCancel }: { state: JourneyDayImportState; onCancel: () => void }) {
const { t } = useTranslation();
const { problem, busy } = state;
const working = busy !== null;
if (state.outcome?.kind === 'route') {
return (
<>
<FooterSpacer />
<DialogButton onClick={onCancel}>{t('common.close')}</DialogButton>
<DialogButton
variant="primary"
onClick={state.retryRoute}
disabled={working}
icon={busy === 'route' ? <Loader2 size={14} className="animate-spin" aria-hidden="true" /> : undefined}
>
{t('journey.dayImport.routeRetry')}
</DialogButton>
</>
);
}
const submit = (
<DialogButton
variant="primary"
onClick={state.submit}
disabled={problem !== null || working || state.loading}
icon={busy === 'submit' ? <Loader2 size={14} className="animate-spin" aria-hidden="true" /> : undefined}
>
{t('journey.dayImport.submit', { count: state.groups.length })}
</DialogButton>
);
return (
<>
{state.skipCount > 0 && (
<Tooltip label={t('journey.dayImport.skipHint')} placement="top">
<button type="button" className="inline-flex cursor-default">
<SoftPill>{t('journey.dayImport.skipped', { count: state.skipCount })}</SoftPill>
</button>
</Tooltip>
)}
<FooterSpacer />
<DialogButton onClick={onCancel}>{t('common.cancel')}</DialogButton>
{problem !== null && state.review && state.review.stays.length > 0 ? (
<Tooltip label={t(`journey.dayImport.problem.${problem}`)} placement="top">
<span className="inline-flex">{submit}</span>
</Tooltip>
) : (
submit
)}
</>
);
}
@@ -0,0 +1,270 @@
import type { JourneyDayStay } from '@trek/shared';
import { Check, Clock, Gauge, HelpCircle, Hourglass, Link2, MapPinOff, Unlink2 } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDuration, timeRange } from '../Dawarich/dawarichSuggestionModel';
import { SoftPill } from '../Planner/planParts';
import { INPUT, PANEL, PillSelect } from '../shared/dialogParts';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { canJoin, isTakeable, nameOf, REPORT_KEY, type DayImportGroup } from './journeyDayImportModel';
import type { JourneyDayImportState } from './useJourneyDayImport';
type Props = { state: JourneyDayImportState; dayColor: string };
/** A fact about a stay with what it means on hover. */
function Hinted({ label, children }: { label: string; children: ReactNode }) {
return (
<Tooltip label={label} placement="top">
<button type="button" className="inline-flex cursor-default">
{children}
</button>
</Tooltip>
);
}
/** Where a stay stands, when it is not simply open: in this journal already, taken elsewhere, or left out before. */
function StatusPill({ stay }: { stay: JourneyDayStay }) {
const { t } = useTranslation();
if (stay.status === 'here') {
return (
<SoftPill tone="success" icon={<Check size={11} aria-hidden="true" />}>
{stay.entry?.title
? t('journey.dayImport.statusHere', { title: stay.entry.title })
: t('journey.dayImport.statusHereUntitled')}
</SoftPill>
);
}
if (stay.status === 'elsewhere') {
return (
<Hinted label={t('journey.dayImport.statusElsewhereHint')}>
<SoftPill icon={<Check size={11} aria-hidden="true" />}>{t('journey.dayImport.statusElsewhere')}</SoftPill>
</Hinted>
);
}
if (stay.status === 'skipped') return <SoftPill>{t('journey.dayImport.statusSkipped')}</SoftPill>;
return null;
}
/** When, how long, how sure Dawarich is and whether it has a place: every fact the recording gives, no more. */
function StayFacts({ stay }: { stay: JourneyDayStay }) {
const { t } = useTranslation();
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
return (
<div className="mt-1.5 flex flex-wrap items-center gap-1">
<SoftPill icon={<Clock size={11} aria-hidden="true" />}>
{timeRange(stay.startedAt, stay.endedAt, is12h)}
</SoftPill>
<SoftPill icon={<Hourglass size={11} aria-hidden="true" />}>{formatDuration(stay.durationMinutes, t)}</SoftPill>
{stay.sourceStatus === 'suggested' && (
<Hinted label={t('dawarich.sourceStatus.suggested')}>
<SoftPill tone="warning" icon={<HelpCircle size={11} aria-hidden="true" />}>
{t('journey.dayImport.unconfirmed')}
</SoftPill>
</Hinted>
)}
{stay.confidenceBand === 'low' && (
<Hinted label={t('dawarich.confidence.low')}>
<SoftPill tone="warning" icon={<Gauge size={11} aria-hidden="true" />}>
{t('dawarich.badge.lowConfidence')}
</SoftPill>
</Hinted>
)}
{stay.lat === null && (
<Hinted label={t('journey.dayImport.noPlaceHint')}>
<SoftPill icon={<MapPinOff size={11} aria-hidden="true" />}>{t('journey.dayImport.noPlace')}</SoftPill>
</Hinted>
)}
<StatusPill stay={stay} />
</div>
);
}
/** One stay: chosen or not, its number on the map, its name to correct, and its facts. */
function StayRow({ state, dayColor, stay, index }: Props & { stay: JourneyDayStay; index: number }) {
const { t } = useTranslation();
const { choice } = state;
const takeable = isTakeable(stay);
const chosen = takeable && !!choice?.selected.has(stay.suggestionId);
const name = choice ? nameOf(choice, stay) : stay.name;
const active = state.activeStay === stay.suggestionId;
return (
<div
data-stay-id={stay.suggestionId}
onMouseEnter={() => state.setActiveStay(stay.suggestionId)}
onMouseLeave={() => state.setActiveStay(null)}
className={`flex items-start gap-2.5 rounded-[12px] border px-2.5 py-2 transition-opacity ${
active ? 'border-edge' : 'border-edge-faint'
} ${chosen ? 'bg-surface-card' : 'bg-surface-secondary opacity-60'}`}
>
<button
type="button"
role="checkbox"
aria-checked={chosen}
aria-label={t('journey.dayImport.choose', { name })}
disabled={!takeable || state.busy !== null}
onClick={() => state.toggle(stay.suggestionId)}
className={`mt-1.5 grid h-[18px] w-[18px] flex-none place-items-center rounded-[5px] border ${
chosen ? 'border-transparent bg-accent text-accent-text' : 'border-edge bg-surface-card'
} disabled:cursor-default`}
>
{chosen && <Check size={12} strokeWidth={3} aria-hidden="true" />}
</button>
<span
aria-hidden="true"
className={`mt-1 grid h-[22px] min-w-[22px] flex-none place-items-center rounded-full border-2 bg-surface-card px-1 font-bold tabular-nums ${
chosen ? 'text-content' : 'border-edge text-content-faint'
}`}
style={{ ...fs(10.5), ...(chosen ? { borderColor: dayColor } : {}) }}
>
{index + 1}
</span>
<div className="min-w-0 flex-1">
<input
value={name}
onChange={(e) => state.rename(stay, e.target.value)}
disabled={!chosen || state.busy !== null}
aria-label={t('journey.dayImport.name', { name: stay.name })}
placeholder={stay.name}
className={INPUT}
/>
<StayFacts stay={stay} />
</div>
</div>
);
}
/** Between two chosen stays: join them into one entry, or separate them again. */
function JoinToggle({ state, stay }: { state: JourneyDayImportState; stay: JourneyDayStay }) {
const { t } = useTranslation();
const joined = !!state.choice?.joined.has(stay.suggestionId);
const label = joined ? t('journey.dayImport.split') : t('journey.dayImport.join');
return (
<div className="flex justify-center">
<button
type="button"
onClick={() => state.join(stay.suggestionId)}
disabled={state.busy !== null}
aria-pressed={joined}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
style={fs(11)}
>
{joined ? <Unlink2 size={12} aria-hidden="true" /> : <Link2 size={12} aria-hidden="true" />}
{label}
</button>
</div>
);
}
/** The title and the map position of an entry made of several stays: both chosen on purpose, both shown. */
function GroupHead({ state, group }: { state: JourneyDayImportState; group: DayImportGroup }) {
const { t } = useTranslation();
const isReport = group.key === REPORT_KEY;
const typed = state.choice?.titles[group.key];
const anchor = state.anchorOf(group.key);
const options = [
...group.stays
.filter((stay) => stay.lat !== null)
.map((stay) => ({
value: String(stay.suggestionId),
label: state.choice ? nameOf(state.choice, stay) : stay.name,
})),
...(isReport ? [{ value: 'none', label: t('journey.dayImport.noPin') }] : []),
];
return (
<div className="flex flex-wrap items-center gap-2">
<input
value={typed ?? (isReport ? state.reportDefault : nameOfAnchor(state, group, anchor))}
onChange={(e) => state.setTitle(group.key, e.target.value)}
aria-label={t('journey.dayImport.groupTitle')}
disabled={state.busy !== null}
className={`${INPUT} min-w-[160px] flex-1`}
/>
{options.length > 0 && (
<PillSelect
quiet
label={t('journey.dayImport.groupPin')}
value={anchor === null ? 'none' : String(anchor)}
options={options}
onChange={(value) => state.setAnchor(group.key, value === 'none' ? null : Number(value))}
/>
)}
</div>
);
}
function nameOfAnchor(state: JourneyDayImportState, group: DayImportGroup, anchor: number | null): string {
const stay = group.stays.find((s) => s.suggestionId === anchor) ?? group.stays[0];
return stay && state.choice ? nameOf(state.choice, stay) : '';
}
/** The day report: one entry for all chosen stays, its title and its one map position. */
function ReportBox({ state }: { state: JourneyDayImportState }) {
const { t } = useTranslation();
const report = state.groups.find((group) => group.kind === 'report');
return (
<div className={PANEL}>
{report ? (
<GroupHead state={state} group={report} />
) : (
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('journey.dayImport.reportNeedsTwo')}
</p>
)}
<p className="m-0 text-content-faint" style={fs(11.5)}>
{t('journey.dayImport.reportHint')}
</p>
</div>
);
}
/**
* The stays of the day in the order they were lived, numbered like their pins:
* chosen ones in front, the rest dimmed, joined ones framed under the title and
* position of the entry they become. In report mode all chosen stays become
* one report, so there is nothing to join.
*/
export function JourneyDayImportStays({ state, dayColor }: Props) {
const { stays, choice } = state;
if (!choice) return null;
const merged = new Map(
state.groups.filter((group) => group.kind === 'merged').map((group) => [group.key, group] as const)
);
const inGroup = new Set([...merged.values()].flatMap((group) => group.stays.slice(1).map((s) => s.suggestionId)));
return (
<div className="flex flex-col gap-1.5">
{choice.mode === 'report' && <ReportBox state={state} />}
{stays.map((stay, index) => {
if (inGroup.has(stay.suggestionId)) return null;
const group = merged.get(stay.suggestionId);
const toggle = canJoin(choice, stays, index) ? <JoinToggle state={state} stay={stay} /> : null;
if (!group) {
return (
<div key={stay.suggestionId} className="flex flex-col gap-1.5">
{toggle}
<StayRow state={state} dayColor={dayColor} stay={stay} index={index} />
</div>
);
}
return (
<div key={stay.suggestionId} className="flex flex-col gap-1.5">
{toggle}
<div className={PANEL}>
<GroupHead state={state} group={group} />
{group.stays.map((member, offset) => {
const at = index + offset;
return (
<div key={member.suggestionId} className="flex flex-col gap-1.5">
{offset > 0 && <JoinToggle state={state} stay={member} />}
<StayRow state={state} dayColor={dayColor} stay={member} index={at} />
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,61 @@
// FE-JRN-DAY-RECORDING-BTN-001 to FE-JRN-DAY-RECORDING-BTN-005
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { buildRecordedDay } from '../../../tests/helpers/journeyRecording';
import { render, screen } from '../../../tests/helpers/render';
import JourneyDayRecordingButton from './JourneyDayRecordingButton';
function renderButton(props: Partial<Parameters<typeof JourneyDayRecordingButton>[0]> = {}) {
const onOpen = vi.fn();
render(
<JourneyDayRecordingButton
day={undefined}
canEdit
canTakeOver={false}
needsConnection={false}
onOpen={onOpen}
{...props}
/>
);
return onOpen;
}
describe('JourneyDayRecordingButton', () => {
it('FE-JRN-DAY-RECORDING-BTN-001: a kept day shows how far it went and whose recording it is, and opens for an editor', async () => {
const onOpen = renderButton({ day: buildRecordedDay() });
const button = screen.getByTestId('journey-day-recording');
expect(button).toHaveTextContent('1.5 km');
expect(button.getAttribute('aria-label')).toMatch(/1\.5 km, taken over by marta/);
await userEvent.click(button);
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('FE-JRN-DAY-RECORDING-BTN-002: a viewer sees the kept day but cannot open it, and a running day says so', async () => {
const onOpen = renderButton({ day: buildRecordedDay({ provisional: true, importedBy: null }), canEdit: false });
const button = screen.getByTestId('journey-day-recording');
expect(button).toHaveAttribute('aria-disabled', 'true');
expect(button.getAttribute('aria-label')).toMatch(/taken over on .*The day was still running then\./);
await userEvent.click(button);
expect(onOpen).not.toHaveBeenCalled();
});
it('FE-JRN-DAY-RECORDING-BTN-003: without a kept day an editor whose Dawarich answers may take it over', async () => {
const onOpen = renderButton({ canTakeOver: true });
await userEvent.click(screen.getByRole('button', { name: 'Take over recording' }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('FE-JRN-DAY-RECORDING-BTN-004: offline the offer stays, dimmed, and says why', async () => {
const onOpen = renderButton({ needsConnection: true });
const button = screen.getByTestId('journey-day-recording');
expect(button).toHaveAttribute('aria-disabled', 'true');
expect(button.getAttribute('aria-label')).toMatch(/connection/);
await userEvent.click(button);
expect(onOpen).not.toHaveBeenCalled();
});
it('FE-JRN-DAY-RECORDING-BTN-005: nothing kept and nothing to offer draws nothing', () => {
renderButton();
expect(screen.queryByTestId('journey-day-recording')).toBeNull();
});
});
@@ -0,0 +1,86 @@
import type { JourneyRecordedDay } from '@trek/shared';
import { Route } from 'lucide-react';
import type React from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance } from '../../utils/units';
import DawarichIcon from '../shared/DawarichIcon';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
// aria-disabled rather than disabled: a disabled button gets no pointer events, and
// the tooltip is what tells a viewer or an offline editor what they are looking at.
const ROUND =
'grid h-7 w-7 place-items-center rounded-full transition-colors hover:bg-surface-hover aria-disabled:cursor-default aria-disabled:opacity-40';
const PILL =
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-bold tabular-nums transition-colors aria-disabled:cursor-default [&:not([aria-disabled=true])]:hover:bg-surface-hover';
/**
* The recorded route of one journey day in its header (JRN-01).
*
* A day that keeps its route shows how far it went, for everyone who can open
* the journey, with whose recording it is and since when in the tooltip; an
* editor opens it to check it against Dawarich again, replace it or drop it.
* A day without one offers an editor whose Dawarich answers to take it over;
* offline the offer stays, dimmed, and says why.
*/
export default function JourneyDayRecordingButton({
day,
canEdit,
canTakeOver,
needsConnection,
onOpen,
}: {
day: JourneyRecordedDay | undefined;
canEdit: boolean;
canTakeOver: boolean;
needsConnection: boolean;
onOpen: () => void;
}): React.ReactElement | null {
const { t, locale } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
if (day) {
const distance = formatDistance(day.stats.distanceMeters / 1000, unit);
const date = new Date(day.savedAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' });
const kept = day.importedBy
? t('journey.recording.keptBy', { distance, name: day.importedBy.username, date })
: t('journey.recording.keptOn', { distance, date });
const label = day.provisional ? `${kept} ${t('journey.recording.keptRunning')}` : kept;
return (
<Tooltip label={label} placement="top">
<button
type="button"
onClick={canEdit ? onOpen : undefined}
aria-disabled={!canEdit}
aria-label={label}
data-testid="journey-day-recording"
className={PILL}
style={{ ...fs(10.5), background: 'var(--vg-surf2)', color: 'var(--vg-ink)' }}
>
<Route size={12} strokeWidth={2.4} aria-hidden="true" />
{distance}
{day.provisional && <span className="h-1.5 w-1.5 rounded-full bg-warning" aria-hidden="true" />}
</button>
</Tooltip>
);
}
if (!canTakeOver && !needsConnection) return null;
const label = canTakeOver ? t('journey.recording.takeOver') : t('dawarich.error.offline');
return (
<Tooltip label={label} placement="top">
<button
type="button"
onClick={canTakeOver ? onOpen : undefined}
aria-disabled={!canTakeOver}
aria-label={label}
data-testid="journey-day-recording"
className={ROUND}
style={{ background: 'var(--vg-surf2)' }}
>
<DawarichIcon size={15} />
</button>
</Tooltip>
);
}
@@ -1,4 +1,4 @@
// FE-JRN-SETTINGS-001 to FE-JRN-SETTINGS-027 // FE-JRN-SETTINGS-001 to FE-JRN-SETTINGS-029
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw' import { http, HttpResponse } from 'msw'
@@ -7,6 +7,7 @@ import { render, screen, waitFor, within, fireEvent, act } from '../../../tests/
import { server } from '../../../tests/helpers/msw/server' import { server } from '../../../tests/helpers/msw/server'
import { useJourneyStore, type JourneyDetail } from '../../store/journeyStore' import { useJourneyStore, type JourneyDetail } from '../../store/journeyStore'
import { JourneySettingsDialog } from './JourneyDetailPageSettingsDialog' import { JourneySettingsDialog } from './JourneyDetailPageSettingsDialog'
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep'
type ToastKind = 'success' | 'error' | 'warning' | 'info' type ToastKind = 'success' | 'error' | 'warning' | 'info'
@@ -43,7 +44,7 @@ function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
} }
} }
function mountDialog(journey: JourneyDetail = buildJourney()) { function mountDialog(journey: JourneyDetail = buildJourney(), recordingPrep?: JourneyRecordingPrepState) {
const onClose = vi.fn() const onClose = vi.fn()
const onSaved = vi.fn() const onSaved = vi.fn()
const onOpenInvite = vi.fn() const onOpenInvite = vi.fn()
@@ -55,6 +56,7 @@ function mountDialog(journey: JourneyDetail = buildJourney()) {
onSaved={onSaved} onSaved={onSaved}
onOpenInvite={onOpenInvite} onOpenInvite={onOpenInvite}
onRefresh={onRefresh} onRefresh={onRefresh}
recordingPrep={recordingPrep}
/>, />,
) )
return { ...utils, onClose, onSaved, onOpenInvite, onRefresh } return { ...utils, onClose, onSaved, onOpenInvite, onRefresh }
@@ -475,4 +477,40 @@ describe('JourneySettingsDialog', () => {
await userEvent.click(screen.getByRole('button', { name: /Bring back dismissed suggestions/ })) await userEvent.click(screen.getByRole('button', { name: /Bring back dismissed suggestions/ }))
expect(onRestoreSuggestions).toHaveBeenCalledTimes(1) expect(onRestoreSuggestions).toHaveBeenCalledTimes(1)
}) })
function prepState(over: Partial<JourneyRecordingPrepState> = {}): JourneyRecordingPrepState {
return {
prep: { enabled: true, connection: 'ready', lastAttemptAt: null, lastSuccessAt: null, lastError: 'unreachable' },
saving: false,
fetching: false,
statusLine: { text: 'The first fetch failed: TREK could not reach that address.', tone: 'warning' },
canFetch: true,
setEnabled: vi.fn(async () => {}),
fetchNow: vi.fn(async () => {}),
dayPrep: () => null,
...over,
}
}
it('FE-JRN-SETTINGS-028: the owner switches the preparation of recorded days and fetches by hand after a failure (JRN-03)', async () => {
const prep = prepState()
mountDialog(buildJourney({ auto_prepare_recordings: 1 }), prep)
expect(screen.getByTestId('journey-prep-status')).toHaveTextContent('The first fetch failed: TREK could not reach that address.')
await userEvent.click(screen.getByRole('button', { name: /Fetch now/ }))
expect(prep.fetchNow).toHaveBeenCalledTimes(1)
await userEvent.click(screen.getByRole('button', { name: 'Prepare recorded days automatically' }))
expect(prep.setEnabled).toHaveBeenCalledWith(false)
})
it('FE-JRN-SETTINGS-029: without the Dawarich addon there is no switch, and no fetch where nothing can be reached', () => {
const { unmount } = mountDialog()
expect(screen.queryByText('Prepare recorded days automatically')).not.toBeInTheDocument()
unmount()
mountDialog(buildJourney(), prepState({ canFetch: false, statusLine: { text: 'Link a trip with dates: only its days are prepared.', tone: 'warning' } }))
expect(screen.getByText('Prepare recorded days automatically')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Fetch now/ })).not.toBeInTheDocument()
})
}) })
@@ -7,6 +7,8 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts' import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
import JourneyShareSection from './JourneyShareSection' import JourneyShareSection from './JourneyShareSection'
import { useJourneySettings } from './useJourneySettings' import { useJourneySettings } from './useJourneySettings'
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep'
import { JourneyRecordingPrepRow, JourneyRecordingPrepStatus } from './JourneyRecordingPrepSetting'
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore' import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog' import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
@@ -31,7 +33,7 @@ function RowAction({ label, onClick, children }: { label: string; onClick: () =>
) )
} }
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions }: { export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions, recordingPrep }: {
journey: JourneyDetail journey: JourneyDetail
onClose: () => void onClose: () => void
onSaved: () => void onSaved: () => void
@@ -39,6 +41,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
onRefresh: () => void onRefresh: () => void
/** Bring back every suggestion waved away card by card. Absent for a viewer. */ /** Bring back every suggestion waved away card by card. Absent for a viewer. */
onRestoreSuggestions?: () => Promise<void> | void onRestoreSuggestions?: () => Promise<void> | void
/** Preparing the recorded days (JRN-03); absent without the Dawarich addon. */
recordingPrep?: JourneyRecordingPrepState
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const labelId = useId() const labelId = useId()
@@ -173,6 +177,16 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</div> </div>
</DialogSection> </DialogSection>
{/* Days recorded in Dawarich, prepared for review while a trip runs (JRN-03) */}
{recordingPrep && (
<DialogSection label={t('journey.settings.recordings')}>
<div className={ROWS}>
<JourneyRecordingPrepRow on={!!journey.auto_prepare_recordings} prep={recordingPrep} rowClass={ROW} />
</div>
<JourneyRecordingPrepStatus prep={recordingPrep} />
</DialogSection>
)}
{/* Places from photos (#1003): the GPS a picture carries puts its entry on the map */} {/* Places from photos (#1003): the GPS a picture carries puts its entry on the map */}
<DialogSection label={t('journey.settings.photosSection')}> <DialogSection label={t('journey.settings.photosSection')}>
<div className={ROWS}> <div className={ROWS}>
@@ -0,0 +1,107 @@
// FE-JOURNEY-LAYEREDMAP-001 to FE-JOURNEY-LAYEREDMAP-003
//
// JRN-04: the journey map with its layers: what the renderer is handed, and a
// picked mark's card.
import type { JourneyMapPlace } from '@trek/shared';
import { act } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import JourneyLayeredMap from './JourneyLayeredMap';
import { ALL_VISIBLE } from './journeyMapLayers';
import type { JourneyMapMark } from './journeyMapMarks';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
const seen = vi.hoisted(() => ({ props: [] as Record<string, unknown>[], engine: [] as string[] }));
vi.mock('./JourneyMap', () => ({
default: (props: Record<string, unknown>) => {
seen.props.push(props);
seen.engine.push('leaflet');
return null;
},
}));
vi.mock('./JourneyMapAuto', () => ({
default: (props: Record<string, unknown>) => {
seen.props.push(props);
seen.engine.push('auto');
return null;
},
}));
const MARK: JourneyMapMark = {
kind: 'plan',
key: 'plan:4',
lat: 45.434,
lng: 12.3388,
color: '#64748b',
place: {
name: 'Piazza San Marco',
trip_title: 'Venice',
days: [],
time: null,
entry_id: null,
} as unknown as JourneyMapPlace,
};
function layers(over: Partial<JourneyMapLayersView> = {}): JourneyMapLayersView {
return {
visibility: ALL_VISIBLE,
toggle: vi.fn(),
controls: [{ layer: 'entries', count: 1, state: 'ready' }],
tracks: [],
marks: [MARK],
plannedEntryIds: new Set(['3']),
...over,
};
}
const last = () => seen.props[seen.props.length - 1];
const PHOTOS = [{ id: 'p', lat: 1, lng: 2, thumbUrl: '/t.jpg' }];
describe('JourneyLayeredMap', () => {
it('FE-JOURNEY-LAYEREDMAP-001: without layers the map draws as it always did, on the renderer asked for', () => {
render(<JourneyLayeredMap engine="leaflet" checkins={[]} entries={[]} photos={PHOTOS} tracks={[]} />);
expect(seen.engine[seen.engine.length - 1]).toBe('leaflet');
expect(last()).toMatchObject({ photos: PHOTOS, marks: undefined, layerVisibility: undefined });
expect(screen.queryByRole('group', { name: 'Map layers' })).toBeNull();
});
it('FE-JOURNEY-LAYEREDMAP-002: hands the renderer the layers, the photos only while their switch is on', () => {
const view = layers();
const { rerender } = render(<JourneyLayeredMap layers={view} checkins={[]} entries={[]} photos={PHOTOS} />);
expect(seen.engine[seen.engine.length - 1]).toBe('auto');
expect(last()).toMatchObject({
photos: PHOTOS,
marks: [MARK],
layerVisibility: ALL_VISIBLE,
plannedEntryIds: view.plannedEntryIds,
});
expect(screen.getByRole('group', { name: 'Map layers' })).toBeInTheDocument();
rerender(
<JourneyLayeredMap
layers={layers({ visibility: { ...ALL_VISIBLE, photos: false } })}
checkins={[]}
entries={[]}
photos={PHOTOS}
/>
);
expect(last().photos).toBeUndefined();
});
it('FE-JOURNEY-LAYEREDMAP-003: a picked mark opens its card, which goes when its layer is switched off', () => {
const onOpenEntry = vi.fn();
const { rerender } = render(
<JourneyLayeredMap layers={layers()} onOpenEntry={onOpenEntry} checkins={[]} entries={[]} />
);
act(() => (last().onMarksPick as (marks: JourneyMapMark[]) => void)([MARK]));
expect(screen.getByRole('dialog', { name: 'Piazza San Marco' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog')).toBeNull();
act(() => (last().onMarksPick as (marks: JourneyMapMark[]) => void)([MARK]));
rerender(<JourneyLayeredMap layers={layers({ marks: [] })} onOpenEntry={onOpenEntry} checkins={[]} entries={[]} />);
expect(screen.queryByRole('dialog')).toBeNull();
});
});
@@ -0,0 +1,107 @@
import { useCallback, useState, type ComponentProps, type Ref } from 'react';
import JourneyMap, { type JourneyMapHandle } from './JourneyMap';
import JourneyMapAuto from './JourneyMapAuto';
import JourneyMapLayerControl from './JourneyMapLayerControl';
import JourneyMapPickCard from './JourneyMapPickCard';
import type { JourneyMapMark } from './journeyMapMarks';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
type MapProps = Omit<
ComponentProps<typeof JourneyMapAuto>,
'ref' | 'layerVisibility' | 'marks' | 'onMarksPick' | 'plannedEntryIds'
> & {
/** CARTO key of a shared page, which the Leaflet renderer reads; see JourneyMap. */
cartoApiKey?: string;
};
interface Props extends MapProps {
ref?: Ref<JourneyMapHandle>;
/** The layers of this map; without them it draws as it always did. */
layers?: JourneyMapLayersView | null;
/** A shared page and the phone timeline draw with Leaflet whatever a setting says. */
engine?: 'auto' | 'leaflet';
/** Icon-only switches, for a phone. */
compactControls?: boolean;
/** How far from the map's top the switches sit, clear of a bar laid over the map. */
controlsTop?: number | string;
/** Leads from a picked place or stay to its entry. */
onOpenEntry?: (entryId: string) => void;
entryTitleOf?: (entryId: number) => string | null;
}
/**
* A journey map with its layers (JRN-04): the renderer, the reader's layer
* switches and the card of a picked plan place or stay. Every journey map
* surface draws through here, the desktop aside, the phone screens and the
* shared page, so the layers mean the same everywhere. A switched-off layer
* is hidden in place: the map is not rebuilt and keeps its view.
*/
export default function JourneyLayeredMap({
ref,
layers,
engine = 'auto',
compactControls = false,
controlsTop = 12,
onOpenEntry,
entryTitleOf,
photos,
tracks,
height = 220,
...rest
}: Props) {
const [picked, setPicked] = useState<{ marks: JourneyMapMark[]; key: string } | null>(null);
const visibility = layers?.visibility;
const onMarksPick = useCallback((marks: JourneyMapMark[]) => {
setPicked({ marks, key: marks.map((m) => m.key).join(',') });
}, []);
const close = useCallback(() => setPicked(null), []);
const openEntry = useCallback(
(entryId: string) => {
setPicked(null);
onOpenEntry?.(entryId);
},
[onOpenEntry]
);
// A pick whose marks a switch took off the map closes with them.
const shownKeys = new Set(layers?.marks.map((m) => m.key));
const card = picked && picked.marks.every((m) => shownKeys.has(m.key)) ? picked : null;
const mapProps = {
...rest,
height,
photos: !visibility || visibility.photos ? photos : undefined,
tracks: layers ? layers.tracks : tracks,
layerVisibility: visibility,
marks: layers?.marks,
onMarksPick,
plannedEntryIds: layers?.plannedEntryIds,
};
return (
<div className="relative w-full" style={{ height: height === 9999 ? '100%' : height }}>
<div className="isolate h-full w-full">
{engine === 'leaflet' ? <JourneyMap ref={ref} {...mapProps} /> : <JourneyMapAuto ref={ref} {...mapProps} />}
</div>
{layers && (
<div
className="pointer-events-none absolute inset-x-3 z-10 flex flex-col items-start gap-2"
style={{ top: controlsTop }}
>
<div className="pointer-events-auto max-w-full">
<JourneyMapLayerControl layers={layers} compact={compactControls} />
</div>
{card && (
<JourneyMapPickCard
key={card.key}
marks={card.marks}
onClose={close}
onOpenEntry={onOpenEntry ? openEntry : undefined}
entryTitleOf={entryTitleOf}
/>
)}
</div>
)}
</div>
);
}
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-050 // FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-054
vi.mock('../../api/websocket', () => ({ vi.mock('../../api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -623,8 +623,31 @@ describe('JourneyMap', () => {
expect(line).toBeDefined(); expect(line).toBeDefined();
// Leaflet wants [lat, lng], which is the order the geometry is stored in. // Leaflet wants [lat, lng], which is the order the geometry is stored in.
expect(line![0]).toEqual([[47.1, 11.2], [47.2, 11.3]]); expect(line![0]).toEqual([[47.1, 11.2], [47.2, 11.3]]);
// Solid, unlike the dashed line that merely connects entries in time order. // A trip's GPX track is the plan: dashed, and wider apart than the faint
expect((line![1] as any).dashArray).toBeUndefined(); // line that merely connects entries in time order.
expect((line![1] as any).dashArray).toBe('8 7');
});
it('FE-COMP-JOURNEYMAP-051: a recorded route is drawn solid, apart from the planned one (JRN-01)', () => {
vi.mocked(L.polyline).mockClear();
render(
<JourneyMap
checkins={[]}
entries={entriesWithCoords}
tracks={[
{ ...track({ color: '#00aa00' }), kind: 'day_recording' as const },
{ ...track({ color: '#0000aa' }), kind: 'dawarich_live' as const },
{ ...track({ color: '#aa0000' }), kind: 'trip_gpx' as const },
]}
/>
);
const style = (color: string) =>
vi.mocked(L.polyline).mock.calls.map(c => c[1] as L.PolylineOptions | undefined).find(o => o?.color === color);
expect(style('#00aa00')).toBeDefined();
expect(style('#00aa00')?.dashArray).toBeUndefined();
expect(style('#0000aa')?.dashArray).toBeUndefined();
expect(style('#aa0000')?.dashArray).toBe('8 7');
}); });
it('FE-COMP-JOURNEYMAP-043: labels a track with the map tooltip the markers use', () => { it('FE-COMP-JOURNEYMAP-043: labels a track with the map tooltip the markers use', () => {
@@ -800,4 +823,59 @@ describe('JourneyMap', () => {
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 })); map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
} }
}); });
// JRN-04: the layer switches hide in place, and the plan's places and stays draw beside the entries.
it('FE-COMP-JOURNEYMAP-052: switching the entries off takes their pin off the map and back, without a rebuild', () => {
const marker = mockedMarkerWithRemove();
const entry = [entriesWithCoords[0]];
const shown = { plan: true, recorded: true, entries: true };
const { rerender } = render(<JourneyMap checkins={[]} entries={entry} layerVisibility={shown} />);
expect(marker.addTo).toHaveBeenCalledTimes(1);
rerender(<JourneyMap checkins={[]} entries={entry} layerVisibility={{ ...shown, entries: false }} />);
expect(marker.remove).toHaveBeenCalledTimes(1);
rerender(<JourneyMap checkins={[]} entries={entry} layerVisibility={shown} />);
expect(marker.addTo).toHaveBeenCalledTimes(2);
expect(L.map).toHaveBeenCalledTimes(1);
}); });
it('FE-COMP-JOURNEYMAP-053: a planned place is drawn as its hollow pin, and a click hands it up', () => {
const onMarksPick = vi.fn();
const mark = {
kind: 'plan' as const,
key: 'plan:4',
lat: 45.43,
lng: 12.33,
color: '#e11d48',
place: {
place_id: 4, trip_id: 2, trip_title: 'Venice', name: 'San Marco', lat: 45.43, lng: 12.33,
days: [], time: null, category_color: '#e11d48', category_icon: null, entry_id: null,
},
};
render(<JourneyMap checkins={[]} entries={[]} marks={[mark]} onMarksPick={onMarksPick} />);
const pins = vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('stroke-dasharray'));
expect(pins).toHaveLength(1);
expect(pins[0]).toContain('#e11d48');
const click = mockedMarker().on.mock.calls.filter((c: unknown[]) => c[0] === 'click').pop()?.[1] as () => void;
click();
expect(onMarksPick).toHaveBeenCalledWith([mark]);
});
it('FE-COMP-JOURNEYMAP-054: an entry pin whose place the plan had too carries the check', () => {
render(<JourneyMap checkins={[]} entries={entriesWithCoords} plannedEntryIds={new Set(['e2'])} />);
expect(divIconHtml(0)).not.toContain('#16a34a');
expect(divIconHtml(1)).toContain('#16a34a');
});
});
/** The shared marker spy with the `remove` a hidden layer is taken off by. */
function mockedMarkerWithRemove(): { addTo: Mock; remove: Mock } {
L.marker([0, 0]);
const marker = mockedMarker() as unknown as { addTo: Mock; remove: Mock };
marker.remove = vi.fn();
vi.mocked(L.marker).mockClear();
marker.addTo.mockClear();
return marker;
}
+185 -91
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react' import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref } from 'react'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer' import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
import L from 'leaflet' import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
@@ -9,6 +9,9 @@ import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLa
import { crsForBasemap } from '../Map/gcj02Crs' import { crsForBasemap } from '../Map/gcj02Crs'
import { escapeHtml, type JourneyTrack } from '@trek/shared' import { escapeHtml, type JourneyTrack } from '@trek/shared'
import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup' import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { PLANNED_BADGE_SVG, type JourneyMapMark } from './journeyMapMarks'
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
import { NOTHING_DRAWN, drawLeafletMarks, switchLeafletLayers, type DrawnLayers, type LeafletLayerKind } from './journeyLeafletLayers'
export interface MapMarkerItem { export interface MapMarkerItem {
id: string id: string
@@ -74,8 +77,32 @@ interface Props {
paddingBottom?: number paddingBottom?: number
/** CARTO key from the share payload: the public journey has no settings store to read. */ /** CARTO key from the share payload: the public journey has no settings store to read. */
cartoApiKey?: string cartoApiKey?: string
/**
* Which drawn layers the reader shows (JRN-04). A hidden one is taken off the
* map and put back as it was, never rebuilt, so the view stays where it is.
*/
layerVisibility?: JourneyMapLayerVisibility
/** The plan's places and the stays of day reports, clustered where they meet. */
marks?: JourneyMapMark[]
onMarksPick?: (marks: JourneyMapMark[]) => void
/** Entry pins whose place the plan had too, marked with a check. */
plannedEntryIds?: ReadonlySet<string>
} }
/** The map's own zoom buttons, frosted so they hold on any tile. */
const zoomButtonStyle = (dark?: boolean): CSSProperties => ({
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
})
const EVERY_LAYER: JourneyMapLayerVisibility = { plan: true, recorded: true, entries: true }
const NO_PLANNED: ReadonlySet<string> = new Set()
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] { function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const items: MapMarkerItem[] = [] const items: MapMarkerItem[] = []
for (const e of entries) { for (const e of entries) {
@@ -101,7 +128,7 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const MARKER_W = 28 const MARKER_W = 28
const MARKER_H = 36 const MARKER_H = 36
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string { function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): string {
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)' const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
const shadow = highlighted const shadow = highlighted
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' ? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
@@ -114,10 +141,28 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/> <path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${dayColor}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${dayColor}"/> <circle cx="14" cy="13" r="8" fill="${dayColor}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text> <text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
${planned ? PLANNED_BADGE_SVG : ''}
</svg> </svg>
</div>` </div>`
} }
function entryIcon(item: MapMarkerItem, highlighted: boolean, planned: boolean): L.DivIcon {
return L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, highlighted, planned),
})
}
function paintEntryMarker(
marker: L.Marker | undefined, item: MapMarkerItem | undefined, highlighted: boolean, planned: ReadonlySet<string>,
): void {
if (!marker || !item) return
marker.setIcon(entryIcon(item, highlighted, planned.has(item.id)))
marker.setZIndexOffset(highlighted ? 1000 : 0)
}
/** /**
* Pan, don't zoom: the initial fitBounds decides how far out the reader starts, * Pan, don't zoom: the initial fitBounds decides how far out the reader starts,
* and a focus keeps that (discussion #2299). * and a focus keeps that (discussion #2299).
@@ -143,9 +188,19 @@ const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const EMPTY_TRACKS: JourneyTrack[] = [] const EMPTY_TRACKS: JourneyTrack[] = []
/** Fallback when a track carries no colour of its own, matching the planner's default. */ /** Fallback when a track carries no colour of its own, matching the planner's default. */
const TRACK_FALLBACK_COLOR = '#4f46e5' const TRACK_FALLBACK_COLOR = '#4f46e5'
/** The dash of a planned track; wide enough not to pass for the faint line between entries. */
const PLANNED_DASH = '8 7'
function markCoords(marks: JourneyMapMark[] | undefined): L.LatLngTuple[] {
return (marks ?? []).map(mark => [mark.lat, mark.lng] as L.LatLngTuple)
}
function fitOptions(paddingBottom: number | undefined): L.FitBoundsOptions {
return { paddingTopLeft: [50, 50], paddingBottomRight: [50, paddingBottom || 50], maxZoom: 16 }
}
function JourneyMap( function JourneyMap(
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, ref }: Props, { entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) { ) {
// Read through a ref: the flag is fixed per surface, and putting it in the // 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. // marker effect's deps would rebuild every marker for nothing.
@@ -169,8 +224,6 @@ function JourneyMap(
const tileLayerRef = useRef<L.TileLayer | null>(null) const tileLayerRef = useRef<L.TileLayer | null>(null)
// GL layer or the raster stand-in a browser without WebGL gets instead (#2288). // GL layer or the raster stand-in a browser without WebGL gets instead (#2288).
const glLayerRef = useRef<BasemapLayer | null>(null) const glLayerRef = useRef<BasemapLayer | null>(null)
// The vector basemap loads async; a map torn down before it lands must not get one.
const cancelledRef = useRef(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null) const mapRef = useRef<L.Map | null>(null)
const markersRef = useRef<Map<string, L.Marker>>(new Map()) const markersRef = useRef<Map<string, L.Marker>>(new Map())
@@ -181,42 +234,36 @@ function JourneyMap(
const photoLayerRef = useRef<L.LayerGroup | null>(null) const photoLayerRef = useRef<L.LayerGroup | null>(null)
const onPhotoClickRef = useRef(onPhotoClick) const onPhotoClickRef = useRef(onPhotoClick)
onPhotoClickRef.current = onPhotoClick onPhotoClickRef.current = onPhotoClick
const markLayerRef = useRef<L.LayerGroup | null>(null)
const darkRef = useRef(dark) const onMarksPickRef = useRef(onMarksPick)
darkRef.current = dark const marksRef = useRef(marks)
// The plan and the stays load after the entries, so the first view is framed
// again once they arrive, around both, unless the reader has moved it by then.
const entryCoordsRef = useRef<L.LatLngTuple[]>([])
const marksFramedRef = useRef(false)
const readerMovedRef = useRef(false)
// What each switchable layer drew, and which of them are on the map right now.
const drawnRef = useRef<DrawnLayers>(NOTHING_DRAWN())
const shownRef = useRef<JourneyMapLayerVisibility>(layerVisibility)
const visibilityRef = useRef(layerVisibility)
const plannedRef = useRef(plannedEntryIds)
const iconPlannedRef = useRef<ReadonlySet<string>>(plannedEntryIds)
// Before the map effects below, so a rebuild draws with what the reader shows now.
useEffect(() => {
onMarksPickRef.current = onMarksPick
marksRef.current = marks
visibilityRef.current = layerVisibility
plannedRef.current = plannedEntryIds
})
const highlightMarker = useCallback((id: string | null) => { const highlightMarker = useCallback((id: string | null) => {
const prev = highlightedRef.current const prev = highlightedRef.current
highlightedRef.current = id highlightedRef.current = id
const isDark = !!darkRef.current const paint = (target: string, highlighted: boolean) => paintEntryMarker(
markersRef.current.get(target), itemsRef.current.find(i => i.id === target), highlighted, plannedRef.current,
if (prev && prev !== id) { )
const marker = markersRef.current.get(prev) if (prev && prev !== id) paint(prev, false)
const item = itemsRef.current.find(i => i.id === prev) if (id) paint(id, true)
if (marker && item) {
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false),
}))
marker.setZIndexOffset(0)
}
}
if (id) {
const marker = markersRef.current.get(id)
const item = itemsRef.current.find(i => i.id === id)
if (marker && item) {
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, true),
}))
marker.setZIndexOffset(1000)
}
}
}, []) }, [])
/** /**
@@ -259,12 +306,20 @@ function JourneyMap(
}) })
L.control.attribution({ position: 'bottomright', prefix: false }).addTo(map) L.control.attribution({ position: 'bottomright', prefix: false }).addTo(map)
mapRef.current = map mapRef.current = map
cancelledRef.current = false // The vector basemap loads async; a map torn down before it lands must not get
// one. Per build, not a shared ref: the next build would reset a shared flag and
// hand the old map, already removed, the layer it was still waiting for.
let cancelled = false
marksFramedRef.current = false
readerMovedRef.current = false
const readerMoved = () => { readerMovedRef.current = true }
map.on('dragstart', readerMoved)
containerRef.current.addEventListener('wheel', readerMoved, { passive: true })
// The basemap is a vector style unless the user brought their own raster // The basemap is a vector style unless the user brought their own raster
// template, so which layer draws it is decided per template rather than once. // template, so which layer draws it is decided per template rather than once.
if (isVectorStyle(tileUrlRef.current)) { if (isVectorStyle(tileUrlRef.current)) {
void attachVectorBasemap(map, tileUrlRef.current, glLayerRef, () => cancelledRef.current) void attachVectorBasemap(map, tileUrlRef.current, glLayerRef, () => cancelled)
} else { } else {
const tiles = L.tileLayer(tileUrlRef.current, { const tiles = L.tileLayer(tileUrlRef.current, {
maxZoom: 18, maxZoom: 18,
@@ -284,6 +339,18 @@ function JourneyMap(
const items = buildMarkerItems(entries) const items = buildMarkerItems(entries)
itemsRef.current = items itemsRef.current = items
// Each switchable layer is drawn whatever its switch says and only put on
// the map while it is shown, so switching it later needs no rebuild.
const drawn = NOTHING_DRAWN()
drawnRef.current = drawn
shownRef.current = visibilityRef.current
iconPlannedRef.current = plannedRef.current
const place = <T extends L.Layer>(kind: LeafletLayerKind, layer: T): T => {
drawn[kind].push(layer)
if (visibilityRef.current[kind]) layer.addTo(map)
return layer
}
const allCoords: L.LatLngTuple[] = [] const allCoords: L.LatLngTuple[] = []
/** /**
* Track geometry is kept OUT of the fit set (#2194) and used only when there * Track geometry is kept OUT of the fit set (#2194) and used only when there
@@ -305,41 +372,47 @@ function JourneyMap(
if (stableTrail.length > 1) { if (stableTrail.length > 1) {
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple) const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
L.polyline(coords, { place('recorded', L.polyline(coords, {
color: '#6366f1', weight: 3, opacity: 0.4, color: '#6366f1', weight: 3, opacity: 0.4,
dashArray: '6 4', lineCap: 'round', dashArray: '6 4', lineCap: 'round',
}).addTo(map) }))
coords.forEach(c => allCoords.push(c)) coords.forEach(c => allCoords.push(c))
} }
// GPX tracks — drawn solid and in their own colour, so they read as a recorded // Tracks, in their own colour over a white casing that keeps them legible on
// route rather than as the dashed line that merely connects entries in time order. // satellite tiles, same as the planner map. What was recorded (a day the journey
// A white casing keeps them legible on satellite tiles, same as the planner map. // keeps, the live Dawarich trail) is drawn solid; a trip's GPX track is the plan
// and drawn dashed, so the two never read as the same thing (JRN-01).
for (const track of stableTracks) { for (const track of stableTracks) {
if (track.points.length < 2) continue if (track.points.length < 2) continue
const coords = track.points.map(([lat, lng]) => [lat, lng] as L.LatLngTuple) const coords = track.points.map(([lat, lng]) => [lat, lng] as L.LatLngTuple)
const color = track.color || TRACK_FALLBACK_COLOR const color = track.color || TRACK_FALLBACK_COLOR
L.polyline(coords, { color: '#ffffff', weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' }).addTo(map) const planned = isPlannedTrack(track)
const line = L.polyline(coords, { color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }) const kind: LeafletLayerKind = planned ? 'plan' : 'recorded'
place(kind, L.polyline(coords, { color: '#ffffff', weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' }))
const line = L.polyline(coords, {
color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round',
...(planned ? { dashArray: PLANNED_DASH } : {}),
})
// Same tooltip the markers on this map use, rather than Leaflet's default box: // Same tooltip the markers on this map use, rather than Leaflet's default box:
// it follows the appearance tokens, so it lands right in dark mode and with // it follows the appearance tokens, so it lands right in dark mode and with
// transparency switched off. Escaped because a string handed to bindTooltip // transparency switched off. Escaped because a string handed to bindTooltip
// becomes innerHTML, and a track name is a place name off a shared trip. // becomes innerHTML, and a track name is a place name off a shared trip.
if (track.name) line.bindTooltip(escapeHtml(track.name), { sticky: true, direction: 'top', className: 'map-tooltip' }) if (track.name) line.bindTooltip(escapeHtml(track.name), { sticky: true, direction: 'top', className: 'map-tooltip' })
line.addTo(map) place(kind, line)
coords.forEach(c => trackCoords.push(c)) coords.forEach(c => trackCoords.push(c))
} }
// route trail: dashed line connecting entries in time order, same as JourneyMapGL // route trail: dashed line connecting entries in time order, same as JourneyMapGL
if (items.length > 1) { if (items.length > 1) {
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple) const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
L.polyline(routeCoords, { place('entries', L.polyline(routeCoords, {
color: dark ? '#71717A' : '#A1A1AA', color: dark ? '#71717A' : '#A1A1AA',
weight: 1.5, weight: 1.5,
opacity: 0.5, opacity: 0.5,
dashArray: '4 6', dashArray: '4 6',
lineCap: 'round', lineJoin: 'round', lineCap: 'round', lineJoin: 'round',
}).addTo(map) }))
} }
// place markers // place markers
@@ -347,14 +420,7 @@ function JourneyMap(
const pos: L.LatLngTuple = [item.lat, item.lng] const pos: L.LatLngTuple = [item.lat, item.lng]
allCoords.push(pos) allCoords.push(pos)
const icon = L.divIcon({ const marker = place('entries', L.marker(pos, { icon: entryIcon(item, false, plannedRef.current.has(item.id)) }))
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false),
})
const marker = L.marker(pos, { icon }).addTo(map)
// The same card the GL renderer shows, from the same builder: which map // 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 // engine a reader happens to have selected should not change what a marker
// tells them (discussion #2299). The builder escapes everything that came // tells them (discussion #2299). The builder escapes everything that came
@@ -391,13 +457,12 @@ function JourneyMap(
map.invalidateSize() map.invalidateSize()
// Tracks only get a say when the journey has nothing located of its own — // Tracks only get a say when the journey has nothing located of its own —
// a world view would be worse than framing the one thing on the map. // a world view would be worse than framing the one thing on the map.
const fitCoords = allCoords.length > 0 ? allCoords : trackCoords entryCoordsRef.current = allCoords
if (fitCoords.length > 0) { const pointCoords = [...allCoords, ...markCoords(marksRef.current)]
const pb = paddingBottom || 50 const fitCoords = pointCoords.length > 0 ? pointCoords : trackCoords
map.fitBounds(L.latLngBounds(fitCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 }) marksFramedRef.current = (marksRef.current?.length ?? 0) > 0
} else { if (fitCoords.length > 0) map.fitBounds(L.latLngBounds(fitCoords), fitOptions(paddingBottom))
map.setView([30, 0], 2) else map.setView([30, 0], 2)
}
} catch {} } catch {}
}) })
@@ -405,17 +470,36 @@ function JourneyMap(
if (mapRef.current) map.invalidateSize() if (mapRef.current) map.invalidateSize()
}, 200) }, 200)
const container = containerRef.current
return () => { return () => {
cancelledRef.current = true cancelled = true
container.removeEventListener('wheel', readerMoved)
map.remove() map.remove()
mapRef.current = null mapRef.current = null
tileLayerRef.current = null tileLayerRef.current = null
detachBasemapLayer(glLayerRef.current) detachBasemapLayer(glLayerRef.current)
glLayerRef.current = null glLayerRef.current = null
markersRef.current.clear() markersRef.current.clear()
drawnRef.current = NOTHING_DRAWN()
} }
}, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom, isGcjBasemap]) }, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom, isGcjBasemap])
// A switch puts its layer back on the map or takes it off, exactly as drawn.
useEffect(() => {
shownRef.current = switchLeafletLayers(mapRef.current, drawnRef.current, shownRef.current, layerVisibility)
}, [layerVisibility])
// The plan's check on an entry pin follows the plan layer without redrawing the map.
useEffect(() => {
const before = iconPlannedRef.current
if (before === plannedEntryIds) return
for (const item of itemsRef.current) {
if (before.has(item.id) === plannedEntryIds.has(item.id)) continue
markersRef.current.get(item.id)?.setIcon(entryIcon(item, highlightedRef.current === item.id, plannedEntryIds.has(item.id)))
}
iconPlannedRef.current = plannedEntryIds
}, [plannedEntryIds])
// Retile in place rather than through the effect above, which would drop every // 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 // marker and track it just drew. A vector basemap restyles instead, which also
// avoids spending a WebGL context on every theme toggle. // avoids spending a WebGL context on every theme toggle.
@@ -477,6 +561,35 @@ function JourneyMap(
} }
}, [photos, entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom]) }, [photos, entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
// The plan's places and the stays of day reports (JRN-04), in their own layer
// like the photos: below the entry pins and above the thumbnails, regrouped
// on every move because marks are only joined where they would overlap.
useEffect(() => {
const map = mapRef.current
if (!map) return
const draw = () => {
markLayerRef.current?.remove()
markLayerRef.current = drawLeafletMarks(map, marks, members => onMarksPickRef.current?.(members))
}
const coords = markCoords(marks)
if (!marksFramedRef.current && coords.length > 0) {
marksFramedRef.current = true
if (!readerMovedRef.current) map.fitBounds(L.latLngBounds([...entryCoordsRef.current, ...coords]), fitOptions(paddingBottom))
}
draw()
map.on('zoomend', draw)
map.on('moveend', draw)
return () => {
map.off('zoomend', draw)
map.off('moveend', draw)
markLayerRef.current?.remove()
markLayerRef.current = null
}
}, [marks, entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
// react to activeMarkerId prop changes — runs after map is built // react to activeMarkerId prop changes — runs after map is built
useEffect(() => { useEffect(() => {
if (!activeMarkerId || !mapRef.current) return if (!activeMarkerId || !mapRef.current) return
@@ -490,8 +603,11 @@ function JourneyMap(
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [activeMarkerId]) }, [activeMarkerId])
const zoomIn = () => mapRef.current?.zoomIn() const zoom = (closer: boolean) => {
const zoomOut = () => mapRef.current?.zoomOut() readerMovedRef.current = true
if (closer) mapRef.current?.zoomIn()
else mapRef.current?.zoomOut()
}
return ( return (
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}> <div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
@@ -500,30 +616,8 @@ function JourneyMap(
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
/> />
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
<button type="button" <button type="button" onClick={() => zoom(true)} style={zoomButtonStyle(dark)}>+</button>
onClick={zoomIn} <button type="button" onClick={() => zoom(false)} style={zoomButtonStyle(dark)}>−</button>
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
}}
>+</button>
<button type="button"
onClick={zoomOut}
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700, lineHeight: 1,
}}
>−</button>
</div> </div>
</div> </div>
) )
@@ -7,6 +7,8 @@ import type { JourneyMapGLHandle } from './JourneyMapGL'
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy' import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
import type { JourneyTrack } from '@trek/shared' import type { JourneyTrack } from '@trek/shared'
import type { MapPhoto } from './journeyPhotoLayer' import type { MapPhoto } from './journeyPhotoLayer'
import type { JourneyMapLayerVisibility } from './journeyMapLayers'
import type { JourneyMapMark } from './journeyMapMarks'
// Unified handle — both providers expose the same three methods. // Unified handle — both providers expose the same three methods.
export type JourneyMapAutoHandle = JourneyMapHandle export type JourneyMapAutoHandle = JourneyMapHandle
@@ -41,6 +43,11 @@ interface Props {
/** Geotagged photos as thumbnails on the map, both renderers (#2453). */ /** Geotagged photos as thumbnails on the map, both renderers (#2453). */
photos?: MapPhoto[] photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void onPhotoClick?: (photoIds: string[]) => void
/** JRN-04: the layers the reader shows, and the plan and stay marks, both renderers. */
layerVisibility?: JourneyMapLayerVisibility
marks?: JourneyMapMark[]
onMarksPick?: (marks: JourneyMapMark[]) => void
plannedEntryIds?: ReadonlySet<string>
} }
function JourneyMapAuto({ ref, ...props }: Props) { function JourneyMapAuto({ ref, ...props }: Props) {
@@ -1,4 +1,4 @@
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-026, FE-COMP-JMAPGL-2453 // FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-040, FE-COMP-JMAPGL-2453
type Spy = ReturnType<typeof vi.fn> type Spy = ReturnType<typeof vi.fn>
@@ -48,6 +48,7 @@ const gl = vi.hoisted(() => {
getSource: vi.fn((): unknown => null), getSource: vi.fn((): unknown => null),
setTerrain: vi.fn(), setTerrain: vi.fn(),
setConfigProperty: vi.fn(), setConfigProperty: vi.fn(),
setLayoutProperty: vi.fn(),
} }
const markers: MarkerStub[] = [] const markers: MarkerStub[] = []
const popups: PopupStub[] = [] const popups: PopupStub[] = []
@@ -270,6 +271,34 @@ describe('JourneyMapGL', () => {
expect(setData).toHaveBeenCalledWith(expect.objectContaining({ type: 'Feature' })) expect(setData).toHaveBeenCalledWith(expect.objectContaining({ type: 'Feature' }))
}) })
it('FE-COMP-JMAPGL-038: a planned GPX track goes on the dashed layer, a recorded route on the solid one (JRN-01)', () => {
withToken()
render(
<JourneyMapGL
checkins={[]}
entries={singleEntry}
tracks={[
{ place_id: 1, trip_id: 1, name: 'Ridge', color: '#aa0000', points: [[47, 11], [47.1, 11.1]] },
{ place_id: -100001, trip_id: 0, name: 'Recorded on 14 May', color: '#00aa00', points: [[47, 11], [47.2, 11.2]], kind: 'day_recording' },
]}
/>,
)
const source = vi.mocked(gl.map.addSource).mock.calls.find(c => c[0] === 'journey-tracks')?.[1] as
| { data: { features: Array<{ properties: { planned: boolean } }> } }
| undefined
expect(source?.data.features.map(f => f.properties.planned)).toEqual([true, false])
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
id: 'journey-tracks-planned',
filter: ['==', ['get', 'planned'], true],
paint: expect.objectContaining({ 'line-dasharray': [2.2, 2] }),
}))
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
id: 'journey-tracks-line',
filter: ['!=', ['get', 'planned'], true],
}))
})
it('FE-COMP-JMAPGL-009: a single entry draws no route line', () => { it('FE-COMP-JMAPGL-009: a single entry draws no route line', () => {
withToken() withToken()
render(<JourneyMapGL checkins={[]} entries={singleEntry} />) render(<JourneyMapGL checkins={[]} entries={singleEntry} />)
@@ -658,4 +687,38 @@ describe('JourneyMapGL', () => {
const { container } = render(<JourneyMapGL checkins={[]} entries={entries} height={9999} />) const { container } = render(<JourneyMapGL checkins={[]} entries={entries} height={9999} />)
expect((container.firstChild as HTMLElement).style.height).toBe('100%') expect((container.firstChild as HTMLElement).style.height).toBe('100%')
}) })
it('FE-COMP-JMAPGL-039: the plan has its own casing, and a layer switched off is added hidden (JRN-04)', () => {
withToken()
render(
<JourneyMapGL
checkins={[]}
entries={entries}
tracks={[{ place_id: 1, trip_id: 1, name: 'Ridge', color: '#aa0000', points: [[47, 11], [47.1, 11.1]] }]}
layerVisibility={{ plan: false, recorded: true, entries: false }}
/>,
)
const layer = (id: string) => vi.mocked(gl.map.addLayer).mock.calls.map(c => c[0] as { id: string; layout: Record<string, unknown>; filter?: unknown }).find(l => l.id === id)
expect(layer('journey-tracks-planned-casing')?.filter).toEqual(['==', ['get', 'planned'], true])
expect(layer('journey-tracks-planned-casing')?.layout.visibility).toBe('none')
expect(layer('journey-tracks-planned')?.layout.visibility).toBe('none')
expect(layer('journey-tracks-line')?.layout.visibility).toBeUndefined()
expect(layer('journey-route-line')?.layout.visibility).toBe('none')
expect(gl.markers.every(m => m.element.style.display === 'none')).toBe(true)
})
it('FE-COMP-JMAPGL-040: a switch shows and hides its layers in place, and the check follows the plan', () => {
withToken()
const shown = { plan: true, recorded: true, entries: true }
const { rerender } = render(<JourneyMapGL checkins={[]} entries={entries} layerVisibility={shown} />)
expect(gl.markers[0].element.innerHTML).not.toContain('#16a34a')
rerender(<JourneyMapGL checkins={[]} entries={entries} layerVisibility={{ ...shown, recorded: false }} plannedEntryIds={new Set(['e1'])} />)
expect(gl.map.setLayoutProperty).toHaveBeenCalledWith('journey-tracks-line', 'visibility', 'none')
expect(gl.map.setLayoutProperty).toHaveBeenCalledWith('journey-tracks-casing', 'visibility', 'none')
expect(gl.map.setLayoutProperty).not.toHaveBeenCalledWith('journey-tracks-planned', 'visibility', expect.anything())
expect(gl.markers[0].element.innerHTML).toContain('#16a34a')
expect(mapboxgl.Map).toHaveBeenCalledTimes(1)
})
}) })
+175 -19
View File
@@ -6,6 +6,8 @@ import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlM
import type { JourneyTrack } from '@trek/shared' import type { JourneyTrack } from '@trek/shared'
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup' import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer' import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
import { MARK_CLUSTER_PX, PLANNED_BADGE_SVG, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks'
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
export interface JourneyMapGLHandle { export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void highlightMarker: (id: string | null) => void
@@ -52,6 +54,11 @@ interface Props {
photos?: MapPhoto[] photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void onPhotoClick?: (photoIds: string[]) => void
paddingBottom?: number paddingBottom?: number
/** See the Leaflet twin (JRN-04): hidden layers go off the map in place. */
layerVisibility?: JourneyMapLayerVisibility
marks?: JourneyMapMark[]
onMarksPick?: (marks: JourneyMapMark[]) => void
plannedEntryIds?: ReadonlySet<string>
glProvider?: GlMapProvider glProvider?: GlMapProvider
/** /**
* The GL engine, injected instead of imported. Both SDKs used to be pulled in * The GL engine, injected instead of imported. Both SDKs used to be pulled in
@@ -98,7 +105,16 @@ function buildItems(entries: MapEntry[]): Item[] {
return items return items
} }
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement { /** The map's own layers each switch takes off and puts back. */
const LAYER_IDS: Record<keyof JourneyMapLayerVisibility, string[]> = {
plan: ['journey-tracks-planned-casing', 'journey-tracks-planned'],
recorded: ['journey-tracks-casing', 'journey-tracks-line'],
entries: ['journey-route-line'],
}
const EVERY_LAYER: JourneyMapLayerVisibility = { plan: true, recorded: true, entries: true }
const NO_PLANNED: ReadonlySet<string> = new Set()
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): HTMLDivElement {
const fill = dayColor const fill = dayColor
const textColor = '#fff' const textColor = '#fff'
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)' const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
@@ -121,6 +137,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/> <path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/> <circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text> <text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="'Poppins',system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
${planned ? PLANNED_BADGE_SVG : ''}
</svg>` </svg>`
wrap.appendChild(inner) wrap.appendChild(inner)
return wrap return wrap
@@ -131,8 +148,24 @@ const EMPTY_TRACKS: JourneyTrack[] = []
/** Fallback when a track carries no colour of its own, matching the planner's default. */ /** Fallback when a track carries no colour of its own, matching the planner's default. */
const TRACK_FALLBACK_COLOR = '#4f46e5' const TRACK_FALLBACK_COLOR = '#4f46e5'
function markBounds(gl: Props['gl'], marks: JourneyMapMark[], points: [number, number][] = []) {
const bounds = new gl.LngLatBounds()
points.forEach(point => bounds.extend(point))
marks.forEach(mark => bounds.extend([mark.lng, mark.lat]))
return bounds
}
function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefined) {
return {
padding: { top: 50, bottom: paddingBottom || 50, left: 50, right: 50 },
maxZoom: 16,
pitch: tilted ? 45 : 0,
duration: 0,
}
}
function JourneyMapGL( function JourneyMapGL(
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, ref }: Props, { entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
) { ) {
const hideMarkerTooltipRef = useRef(hideMarkerTooltip) const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
hideMarkerTooltipRef.current = hideMarkerTooltip hideMarkerTooltipRef.current = hideMarkerTooltip
@@ -165,6 +198,25 @@ function JourneyMapGL(
const onPhotoClickRef = useRef(onPhotoClick) const onPhotoClickRef = useRef(onPhotoClick)
onPhotoClickRef.current = onPhotoClick onPhotoClickRef.current = onPhotoClick
const photoMarkersRef = useRef<{ remove: () => void }[]>([]) const photoMarkersRef = useRef<{ remove: () => void }[]>([])
const markMarkersRef = useRef<{ remove: () => void }[]>([])
const onMarksPickRef = useRef(onMarksPick)
const marksRef = useRef(marks)
// As on the Leaflet map: the plan and the stays arrive after the entries, so
// the first view is framed again around both, unless the reader moved it.
const entryPointsRef = useRef<[number, number][]>([])
const marksFramedRef = useRef(false)
const readerMovedRef = useRef(false)
const visibilityRef = useRef(layerVisibility)
const shownRef = useRef<JourneyMapLayerVisibility>(layerVisibility)
const plannedRef = useRef(plannedEntryIds)
const iconPlannedRef = useRef<ReadonlySet<string>>(plannedEntryIds)
// Before the map effects below, so a rebuild draws with what the reader shows now.
useEffect(() => {
onMarksPickRef.current = onMarksPick
marksRef.current = marks
visibilityRef.current = layerVisibility
plannedRef.current = plannedEntryIds
})
const darkRef = useRef(dark) const darkRef = useRef(dark)
darkRef.current = dark darkRef.current = dark
const mapLangRef = useRef(mapLang) const mapLangRef = useRef(mapLang)
@@ -242,7 +294,7 @@ function JourneyMapGL(
if (!currentInner) return if (!currentInner) return
// Only swap the inner element's styles/HTML. Touching `el.style.cssText` // Only swap the inner element's styles/HTML. Touching `el.style.cssText`
// would wipe mapbox's positional transform and make the marker flicker. // would wipe mapbox's positional transform and make the marker flicker.
const next = markerHtml(item.dayColor, item.dayLabel, highlighted) const next = markerHtml(item.dayColor, item.dayLabel, highlighted, plannedRef.current.has(id))
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
currentInner.style.cssText = nextInner.style.cssText currentInner.style.cssText = nextInner.style.cssText
currentInner.innerHTML = nextInner.innerHTML currentInner.innerHTML = nextInner.innerHTML
@@ -314,6 +366,17 @@ function JourneyMapGL(
const map = new gl.Map(mapOptions as any) const map = new gl.Map(mapOptions as any)
mapRef.current = map mapRef.current = map
marksFramedRef.current = false
entryPointsRef.current = [...items.map(i => [i.lng, i.lat] as [number, number]), ...stableTrail.map(p => [p.lng, p.lat] as [number, number])]
readerMovedRef.current = false
map.on('movestart', (ev: { originalEvent?: unknown }) => {
if (ev.originalEvent) readerMovedRef.current = true
})
shownRef.current = visibilityRef.current
iconPlannedRef.current = plannedRef.current
// A layer whose switch is off is added hidden, so switching it on later needs no rebuild.
const layout = (kind: keyof JourneyMapLayerVisibility, base: Record<string, unknown>) =>
visibilityRef.current[kind] ? base : { ...base, visibility: 'none' }
map.on('load', () => { map.on('load', () => {
if (enableMapbox3d) { if (enableMapbox3d) {
@@ -352,14 +415,16 @@ function JourneyMapGL(
'line-opacity': 0.5, 'line-opacity': 0.5,
'line-dasharray': [2, 3], 'line-dasharray': [2, 3],
}, },
layout: { 'line-cap': 'round', 'line-join': 'round' }, layout: layout('entries', { 'line-cap': 'round', 'line-join': 'round' }),
}) })
} }
} }
// GPX tracks — one source for all of them, coloured per feature so a journey // Tracks, one source for all of them, coloured per feature so a journey
// with several recorded routes keeps them apart. Casing underneath, same as the // with several routes keeps them apart. Casing underneath, same as the
// planner map, so a track stays readable on satellite tiles. // planner map, so a track stays readable on satellite tiles. A trip's GPX
// track is the plan and goes on a dashed layer, what was recorded on a solid
// one (JRN-01): a dash pattern cannot follow a feature property.
if (stableTracks.length > 0) { if (stableTracks.length > 0) {
const featureCollection: GeoJSON.FeatureCollection = { const featureCollection: GeoJSON.FeatureCollection = {
type: 'FeatureCollection', type: 'FeatureCollection',
@@ -367,7 +432,11 @@ function JourneyMapGL(
.filter(track => track.points.length > 1) .filter(track => track.points.length > 1)
.map(track => ({ .map(track => ({
type: 'Feature' as const, type: 'Feature' as const,
properties: { color: track.color || TRACK_FALLBACK_COLOR, name: track.name }, properties: {
color: track.color || TRACK_FALLBACK_COLOR,
name: track.name,
planned: isPlannedTrack(track),
},
geometry: { type: 'LineString' as const, coordinates: track.points.map(([lat, lng]) => [lng, lat]) }, geometry: { type: 'LineString' as const, coordinates: track.points.map(([lat, lng]) => [lng, lat]) },
})), })),
} }
@@ -376,19 +445,38 @@ function JourneyMapGL(
(map.getSource('journey-tracks') as mapboxgl.GeoJSONSource).setData(featureCollection) (map.getSource('journey-tracks') as mapboxgl.GeoJSONSource).setData(featureCollection)
} else { } else {
map.addSource('journey-tracks', { type: 'geojson', data: featureCollection }) map.addSource('journey-tracks', { type: 'geojson', data: featureCollection })
// Casing apart for the plan and what was recorded, so each goes with its switch.
map.addLayer({ map.addLayer({
id: 'journey-tracks-casing', id: 'journey-tracks-casing',
type: 'line', type: 'line',
source: 'journey-tracks', source: 'journey-tracks',
filter: ['!=', ['get', 'planned'], true],
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 }, paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 },
layout: { 'line-cap': 'round', 'line-join': 'round' }, layout: layout('recorded', { 'line-cap': 'round', 'line-join': 'round' }),
})
map.addLayer({
id: 'journey-tracks-planned-casing',
type: 'line',
source: 'journey-tracks',
filter: ['==', ['get', 'planned'], true],
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 },
layout: layout('plan', { 'line-cap': 'round', 'line-join': 'round' }),
}) })
map.addLayer({ map.addLayer({
id: 'journey-tracks-line', id: 'journey-tracks-line',
type: 'line', type: 'line',
source: 'journey-tracks', source: 'journey-tracks',
filter: ['!=', ['get', 'planned'], true],
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95 }, paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95 },
layout: { 'line-cap': 'round', 'line-join': 'round' }, layout: layout('recorded', { 'line-cap': 'round', 'line-join': 'round' }),
})
map.addLayer({
id: 'journey-tracks-planned',
type: 'line',
source: 'journey-tracks',
filter: ['==', ['get', 'planned'], true],
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95, 'line-dasharray': [2.2, 2] },
layout: layout('plan', { 'line-cap': 'butt', 'line-join': 'round' }),
}) })
} }
} }
@@ -396,9 +484,10 @@ function JourneyMapGL(
// markers // markers
items.forEach((item) => { items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false) const el = markerHtml(item.dayColor, item.dayLabel, false, plannedRef.current.has(item.id))
// Above the photo thumbnails, which stay at auto: the itinerary is the point of the map (#2453). // Above the photo thumbnails, which stay at auto: the itinerary is the point of the map (#2453).
el.style.zIndex = '1' el.style.zIndex = '1'
if (!visibilityRef.current.entries) el.style.display = 'none'
const marker = new gl.Marker({ element: el, anchor: 'bottom' }) const marker = new gl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat]) .setLngLat([item.lng, item.lat])
.addTo(map) .addTo(map)
@@ -410,15 +499,12 @@ function JourneyMapGL(
}) })
// fit bounds to all points // fit bounds to all points
if (hasPoints) { const known = marksRef.current ?? []
const pb = paddingBottom || 50 marksFramedRef.current = known.length > 0
known.forEach(mark => bounds.extend([mark.lng, mark.lat]))
if (hasPoints || known.length > 0) {
try { try {
map.fitBounds(bounds, { map.fitBounds(bounds, fitOptions(paddingBottom, enableMapbox3d && fullScreen))
padding: { top: 50, bottom: pb, left: 50, right: 50 },
maxZoom: 16,
pitch: enableMapbox3d && fullScreen ? 45 : 0,
duration: 0,
})
} catch { /* empty bounds */ } } catch { /* empty bounds */ }
} }
}) })
@@ -472,6 +558,76 @@ function JourneyMapGL(
// gl is the injected engine and does not change for the life of a map. // gl is the injected engine and does not change for the life of a map.
}, [photos, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom]) }, [photos, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// A switch shows or hides its layers where they are (JRN-04): the GL layers by
// their visibility, the entry pins by their element.
useEffect(() => {
const map = mapRef.current
if (!map) return
for (const kind of Object.keys(LAYER_IDS) as (keyof JourneyMapLayerVisibility)[]) {
if (shownRef.current[kind] === layerVisibility[kind]) continue
for (const id of LAYER_IDS[kind]) {
try { map.setLayoutProperty(id, 'visibility', layerVisibility[kind] ? 'visible' : 'none') } catch { /* not drawn on this map */ }
}
if (kind === 'entries') {
markersRef.current.forEach(m => { m.getElement().style.display = layerVisibility.entries ? '' : 'none' })
}
}
shownRef.current = layerVisibility
}, [layerVisibility])
// The plan's check on an entry pin follows the plan layer in place.
useEffect(() => {
const before = iconPlannedRef.current
if (before === plannedEntryIds) return
for (const item of itemsRef.current) {
if (before.has(item.id) !== plannedEntryIds.has(item.id)) setMarkerStyle(item.id, highlightedRef.current === item.id)
}
iconPlannedRef.current = plannedEntryIds
}, [plannedEntryIds, setMarkerStyle])
// The plan's places and the stays (JRN-04), the GL twin of the Leaflet layer:
// regrouped on every move, between the photos and the entry pins.
useEffect(() => {
const map = mapRef.current
if (!map) return
const clear = () => {
markMarkersRef.current.forEach(m => m.remove())
markMarkersRef.current = []
}
const draw = () => {
clear()
if (!marks?.length) return
if (!marksFramedRef.current) {
marksFramedRef.current = true
if (!readerMovedRef.current) {
try {
map.fitBounds(markBounds(gl, marks, entryPointsRef.current), fitOptions(paddingBottom, enableMapbox3d && fullScreen))
} catch { /* empty bounds */ }
}
}
for (const hit of clusterPoints(marks, (lat, lng) => map.project([lng, lat]), MARK_CLUSTER_PX)) {
const look = markHitSvg(hit.members)
const el = document.createElement('div')
el.style.cssText = `width:${look.width}px;height:${look.height}px;cursor:pointer`
el.innerHTML = look.html
el.addEventListener('click', (ev) => {
ev.stopPropagation()
onMarksPickRef.current?.(hit.members)
})
markMarkersRef.current.push(new gl.Marker({ element: el, anchor: look.anchor }).setLngLat([hit.lng, hit.lat]).addTo(map))
}
}
if (map.loaded()) draw()
else map.once('load', draw)
map.on('moveend', draw)
return () => {
map.off('moveend', draw)
map.off('load', draw)
clear()
}
// gl is the injected engine and does not change for the life of a map.
}, [marks, gl, entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// Switching the UI language has to repin the basemap labels without tearing // Switching the UI language has to repin the basemap labels without tearing
// the map down. The load handler covers the initial run. // the map down. The load handler covers the initial run.
useEffect(() => { useEffect(() => {
@@ -0,0 +1,68 @@
// FE-JOURNEY-MAPLAYERCTL-001 to FE-JOURNEY-MAPLAYERCTL-003
//
// JRN-04: the journey map's layer switches and its key.
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import JourneyMapLayerControl from './JourneyMapLayerControl';
import { ALL_VISIBLE, type JourneyMapLayerControl as Control } from './journeyMapLayers';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
function view(controls: Control[], over: Partial<JourneyMapLayersView> = {}): JourneyMapLayersView {
return {
visibility: ALL_VISIBLE,
toggle: vi.fn(),
controls,
tracks: [],
marks: [],
plannedEntryIds: new Set(),
...over,
};
}
describe('JourneyMapLayerControl', () => {
it('FE-JOURNEY-MAPLAYERCTL-001: one pressed switch per offered layer with its count, a withheld one not at all', () => {
const layers = view(
[
{ layer: 'recorded', count: 2, state: 'ready' },
{ layer: 'entries', count: 5, state: 'ready' },
{ layer: 'photos', count: 0, state: 'empty' },
],
{ visibility: { ...ALL_VISIBLE, photos: false } }
);
render(<JourneyMapLayerControl layers={layers} />);
expect(screen.queryByTestId('journey-map-layer-plan')).toBeNull();
expect(screen.getByTestId('journey-map-layer-recorded')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('journey-map-layer-entries-badge')).toHaveTextContent('5');
const photos = screen.getByTestId('journey-map-layer-photos');
expect(photos).toHaveAttribute('aria-pressed', 'false');
expect(photos.className).toContain('opacity-55');
expect(screen.queryByTestId('journey-map-layer-photos-badge')).toBeNull();
fireEvent.click(photos);
expect(layers.toggle).toHaveBeenCalledWith('photos');
});
it('FE-JOURNEY-MAPLAYERCTL-002: a layer that could not be read carries the warning tone, a loading one the spinner', () => {
render(
<JourneyMapLayerControl
layers={view([
{ layer: 'plan', count: 0, state: 'unavailable' },
{ layer: 'entries', count: 0, state: 'loading' },
])}
/>
);
expect(screen.getByTestId('journey-map-layer-plan-badge')).toHaveAttribute('data-tone', 'warning');
expect(screen.getByTestId('journey-map-layer-entries')).toHaveAttribute('data-state', 'loading');
expect(screen.getByRole('status')).toHaveTextContent('Plan');
});
it('FE-JOURNEY-MAPLAYERCTL-003: the key opens beside the switches and says a plan is no visit', () => {
render(<JourneyMapLayerControl layers={view([{ layer: 'entries', count: 1, state: 'ready' }])} compact />);
expect(screen.queryByText('Entries')).toBeNull();
fireEvent.click(screen.getByTestId('journey-map-key-button'));
expect(screen.getByTestId('journey-map-key')).toHaveTextContent('A planned place is no proof of a visit');
});
});
@@ -0,0 +1,299 @@
import { CalendarRange, Images, Info, Loader2, NotebookPen, Route } from 'lucide-react';
import type { ComponentType, ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { FROSTED_SHELL, segmentStyle } from '../Map/mapControlShadow';
import { LABEL, PopoverListPanel } from '../shared/dialogParts';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { usePopoverList } from '../shared/usePopoverList';
import { LAYER_TEXT, type JourneyMapLayerControl as Control, type JourneyMapLayer } from './journeyMapLayers';
import { PLAN_FALLBACK_COLOR } from './journeyMapMarks';
import type { JourneyMapLayersView } from './useJourneyMapLayers';
const ICONS: Record<JourneyMapLayer, ComponentType<{ size?: number; strokeWidth?: number }>> = {
plan: CalendarRange,
recorded: Route,
entries: NotebookPen,
photos: Images,
};
/** The sample colours of the key: an entry pin of the first day and a recorded line, as the map draws them. */
const SAMPLE_DAY = '#6366f1';
const SAMPLE_ROUTE = '#2563eb';
/** What a switch says about its layer: its name, and why it shows nothing when it does not. */
function hintOf(control: Control, t: (key: string, params?: Record<string, string | number>) => string): string {
const text = LAYER_TEXT[control.layer];
const name = t(text.labelKey);
if (control.state === 'unavailable') return `${name}: ${t(text.unavailableKey)}`;
if (control.state === 'empty') return `${name}: ${t(text.emptyKey)}`;
if (control.state === 'loading') return `${name}: ${t('journey.map.layer.loading')}`;
return name;
}
function LayerToggle({
control,
on,
onToggle,
compact,
}: {
control: Control;
on: boolean;
onToggle: () => void;
compact: boolean;
}) {
const { t } = useTranslation();
const Icon = ICONS[control.layer];
const name = t(LAYER_TEXT[control.layer].labelKey);
const problem = control.state === 'unavailable';
// Nothing to draw: dimmed rather than gone, and the tooltip says why.
const quiet = control.state === 'empty' || control.state === 'idle';
const showBadge = control.state === 'loading' || problem || (control.state === 'ready' && on);
return (
<Tooltip label={hintOf(control, t)} placement="bottom">
<button
type="button"
onClick={onToggle}
aria-label={name}
aria-pressed={on}
data-testid={`journey-map-layer-${control.layer}`}
data-state={control.state}
className={`${on ? 'bg-accent text-accent-text' : 'text-content-muted hover:bg-surface-hover'} ${quiet ? 'opacity-55' : ''} transition-colors`}
style={segmentStyle(on)}
>
<Icon size={14} strokeWidth={2} />
{!compact && <span>{name}</span>}
{showBadge && (
<span
aria-hidden
data-testid={`journey-map-layer-${control.layer}-badge`}
data-tone={problem ? 'warning' : 'default'}
className={`inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1 font-semibold tabular-nums leading-none ${
problem
? 'bg-warning-soft text-warning'
: on
? 'bg-accent-text text-accent'
: 'bg-surface-hover text-content-secondary'
}`}
style={fs(10)}
>
{control.state === 'loading' ? (
<Loader2 size={10} strokeWidth={2.5} className="motion-safe:animate-spin" />
) : problem ? (
'!'
) : (
control.count
)}
</span>
)}
</button>
</Tooltip>
);
}
/** A hollow plan pin, as the map draws it. */
function PlanPinSample() {
return (
<svg width="16" height="20" viewBox="0 0 22 28" aria-hidden className="flex-none">
<path
d="M11 26.5C11 26.5 20.5 17.4 20.5 10.2C20.5 5 16.25 1.5 11 1.5C5.75 1.5 1.5 5 1.5 10.2C1.5 17.4 11 26.5 11 26.5Z"
fill="#ffffff"
stroke={PLAN_FALLBACK_COLOR}
strokeWidth="2"
strokeDasharray="3 2"
/>
<circle cx="11" cy="10.5" r="3" fill={PLAN_FALLBACK_COLOR} />
</svg>
);
}
/** A journal entry's pin, with the plan's check when the plan had that place too. */
function EntryPinSample({ planned = false }: { planned?: boolean }) {
return (
<svg width="16" height="20" viewBox="0 0 28 36" aria-hidden className="flex-none">
<path
d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z"
fill={SAMPLE_DAY}
/>
{planned && (
<>
<circle cx="22.5" cy="5.5" r="5" fill="#ffffff" stroke="#16a34a" strokeWidth="1.2" />
<path
d="M20.4 5.6l1.5 1.5 2.8-2.9"
fill="none"
stroke="#16a34a"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
</svg>
);
}
function StaySample() {
return (
<svg width="16" height="16" viewBox="0 0 14 14" aria-hidden className="flex-none">
<circle cx="7" cy="7" r="5" fill={SAMPLE_DAY} stroke="#ffffff" strokeWidth="2" />
</svg>
);
}
function SeveralSample() {
return (
<svg width="18" height="18" viewBox="0 0 28 28" aria-hidden className="flex-none">
<circle cx="16" cy="12" r="10" fill="#ffffff" stroke={SAMPLE_DAY} strokeWidth="2" />
<circle cx="12" cy="16" r="10" fill="#ffffff" stroke={PLAN_FALLBACK_COLOR} strokeWidth="2.5" />
<text x="12" y="16" textAnchor="middle" dominantBaseline="central" fontSize="11" fontWeight="700" fill="#111827">
3
</text>
</svg>
);
}
function LineSample({ dashed }: { dashed: boolean }) {
return (
<svg width="24" height="8" viewBox="0 0 24 8" aria-hidden className="flex-none">
<line x1="1" y1="4" x2="23" y2="4" stroke="#ffffff" strokeWidth="5" strokeLinecap="round" />
<line
x1="1"
y1="4"
x2="23"
y2="4"
stroke={SAMPLE_ROUTE}
strokeWidth="2.5"
strokeLinecap={dashed ? 'butt' : 'round'}
strokeDasharray={dashed ? '4 3' : undefined}
/>
</svg>
);
}
const ROW = 'flex min-w-0 items-center gap-2.5 text-content-secondary';
/**
* The key of the journey map: what each pin, dot and line stands for, with the
* one rule that matters most: a planned place is no proof of a visit.
*/
function JourneyMapKey() {
const { t } = useTranslation();
const rows: { sample: ReactNode; labelKey: string }[] = [
{ sample: <PlanPinSample />, labelKey: 'journey.map.legend.planned' },
{ sample: <EntryPinSample />, labelKey: 'journey.map.legend.entry' },
{ sample: <EntryPinSample planned />, labelKey: 'journey.map.legend.plannedWritten' },
{ sample: <StaySample />, labelKey: 'journey.map.legend.stay' },
{ sample: <SeveralSample />, labelKey: 'journey.map.legend.several' },
];
return (
<div className="flex flex-col gap-3 p-1.5" data-testid="journey-map-key">
<span className="font-semibold text-content" style={fs(12.5, 'body')}>
{t('journey.map.legend')}
</span>
<ul className="flex flex-col gap-1.5">
{rows.map((row) => (
<li key={row.labelKey} className={ROW} style={fs(11.5, 'body')}>
<span className="flex w-6 flex-none justify-center">{row.sample}</span>
<span className="min-w-0">{t(row.labelKey)}</span>
</li>
))}
</ul>
<div className="border-t border-edge-faint pt-3">
<span className={LABEL}>{t('journey.map.legend.lines')}</span>
<ul className="flex flex-col gap-1.5">
<li className={ROW} style={fs(11.5, 'body')}>
<span className="flex w-6 flex-none justify-center">
<LineSample dashed />
</span>
<span className="min-w-0">{t('journey.map.legend.plannedRoute')}</span>
</li>
<li className={ROW} style={fs(11.5, 'body')}>
<span className="flex w-6 flex-none justify-center">
<LineSample dashed={false} />
</span>
<span className="min-w-0">{t('journey.map.legend.recordedRoute')}</span>
</li>
</ul>
</div>
<p
className="flex items-start gap-1.5 rounded-lg bg-surface-hover px-2 py-1.5 text-content-secondary"
style={fs(11)}
>
<Info size={12} strokeWidth={2} aria-hidden className="mt-px flex-none" />
{t('journey.map.legend.planIsNoVisit')}
</p>
</div>
);
}
function KeyButton() {
const { t } = useTranslation();
const list = usePopoverList(-1, { estimatedHeight: 360 });
const { open, toggle, triggerRef, listId } = list;
const label = t('journey.map.legend');
return (
<>
<Tooltip label={label} placement="bottom" disabled={open}>
<button
ref={triggerRef}
type="button"
onClick={toggle}
aria-label={label}
aria-expanded={open}
aria-controls={open ? listId : undefined}
data-testid="journey-map-key-button"
className={`grid h-[34px] w-[34px] flex-none place-items-center rounded-full text-content-muted transition-colors hover:bg-surface-hover ${open ? 'bg-surface-hover' : ''}`}
>
<Info size={15} strokeWidth={2} />
</button>
</Tooltip>
<PopoverListPanel list={list} label={label} className="w-[268px]">
<JourneyMapKey />
</PopoverListPanel>
</>
);
}
/**
* The layer switches of a journey map (JRN-04), in the frosted pill the
* planner's map controls wear: one per layer the reader is offered, each with
* its count, a spinner while it loads and the warning tone when it could not
* be read, and the key beside them. On a phone the switches drop their words;
* the tooltip and the accessible name still say what each is.
*/
export default function JourneyMapLayerControl({
layers,
compact = false,
}: {
layers: JourneyMapLayersView;
compact?: boolean;
}) {
const { t } = useTranslation();
const live = layers.controls
.filter((control) => control.state === 'loading' || control.state === 'unavailable')
.map((control) => hintOf(control, t))
.join('. ');
return (
<div
style={FROSTED_SHELL}
role="group"
aria-label={t('journey.map.layers')}
className="max-w-full gap-0.5 overflow-x-auto"
>
{layers.controls.map((control) => (
<LayerToggle
key={control.layer}
control={control}
on={layers.visibility[control.layer]}
onToggle={() => layers.toggle(control.layer)}
compact={compact}
/>
))}
<span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />
<KeyButton />
<span role="status" aria-live="polite" className="sr-only">
{live}
</span>
</div>
);
}
@@ -0,0 +1,89 @@
// FE-JOURNEY-MAPPICK-001 to FE-JOURNEY-MAPPICK-003
//
// JRN-04: what a picked plan place or stay is, and several on one spot as a list.
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import JourneyMapPickCard from './JourneyMapPickCard';
import type { JourneyMapMark } from './journeyMapMarks';
const PLACE: JourneyMapPlace = {
place_id: 4,
trip_id: 2,
trip_title: 'Venice',
name: 'Piazza San Marco',
lat: 45.434,
lng: 12.3388,
days: ['2026-05-14', '2026-05-15'],
time: '10:00',
category_color: null,
category_icon: null,
entry_id: null,
};
const STAY: JourneyMapStay = {
id: 9,
entry_id: 7,
name: 'Rialto market',
lat: 45.438,
lng: 12.336,
started_at: '2026-05-14T09:00:00+02:00',
ended_at: '2026-05-14T10:00:00+02:00',
};
const plan = (over: Partial<JourneyMapPlace> = {}): JourneyMapMark => ({
kind: 'plan',
key: 'plan:4',
lat: 45.434,
lng: 12.3388,
color: '#64748b',
place: { ...PLACE, ...over },
});
const stay: JourneyMapMark = { kind: 'stay', key: 'stay:9', lat: 45.438, lng: 12.336, color: '#14b8a6', stay: STAY };
describe('JourneyMapPickCard', () => {
it('FE-JOURNEY-MAPPICK-001: a planned place names its trip and days and says when nothing was written about it', () => {
render(<JourneyMapPickCard marks={[plan()]} onClose={vi.fn()} onOpenEntry={vi.fn()} />);
expect(screen.getByRole('dialog', { name: 'Piazza San Marco' })).toBeInTheDocument();
expect(screen.getByText('Planned')).toBeInTheDocument();
expect(screen.getByText('Venice')).toBeInTheDocument();
expect(screen.getByText('10:00')).toBeInTheDocument();
expect(screen.getByText('Nothing written about it yet.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Open entry' })).toBeNull();
});
it('FE-JOURNEY-MAPPICK-002: a place written about and a stay lead to their entry', () => {
const onOpenEntry = vi.fn();
const { unmount } = render(
<JourneyMapPickCard marks={[plan({ entry_id: 12 })]} onClose={vi.fn()} onOpenEntry={onOpenEntry} />
);
fireEvent.click(screen.getByRole('button', { name: 'Open entry' }));
expect(onOpenEntry).toHaveBeenCalledWith('12');
unmount();
render(
<JourneyMapPickCard
marks={[stay]}
onClose={vi.fn()}
onOpenEntry={onOpenEntry}
entryTitleOf={() => 'A day in Venice'}
/>
);
expect(screen.getByText('Part of “A day in Venice”')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open entry' }));
expect(onOpenEntry).toHaveBeenLastCalledWith('7');
});
it('FE-JOURNEY-MAPPICK-003: several on one spot come as a list, each reachable, and Escape closes', () => {
const onClose = vi.fn();
render(<JourneyMapPickCard marks={[plan(), stay]} onClose={onClose} />);
expect(screen.getByRole('dialog', { name: 'At this spot' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Rialto market/ }));
expect(screen.getByRole('dialog', { name: 'Rialto market' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back to the list' }));
expect(screen.getByRole('button', { name: /Piazza San Marco/ })).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,184 @@
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { CalendarRange, ChevronLeft, ChevronRight, Clock, MapPin, NotebookPen, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import { SoftPill, WhiteBadge } from '../Planner/planParts';
import { fs } from '../shared/DialogShell';
import type { JourneyMapMark } from './journeyMapMarks';
interface Props {
/** What was picked on the map: one mark, or several that lie on one spot. */
marks: JourneyMapMark[];
onClose: () => void;
/** Leads to an entry; absent where the reader cannot open one. */
onOpenEntry?: (entryId: string) => void;
/** The title of an entry the reader can see, for the stay that belongs to it. */
entryTitleOf?: (entryId: number) => string | null;
}
function dayLabel(date: string, locale: string): string {
return new Date(`${date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'short' });
}
function timeLabel(iso: string, locale: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
}
function OpenEntry({ entryId, onOpenEntry }: { entryId: number; onOpenEntry?: (entryId: string) => void }) {
const { t } = useTranslation();
if (!onOpenEntry) return null;
return (
<button
type="button"
onClick={() => onOpenEntry(String(entryId))}
className="inline-flex items-center gap-1 self-start rounded-lg bg-accent px-2.5 py-1.5 font-semibold text-accent-text hover:opacity-90"
style={fs(11.5, 'body')}
>
<NotebookPen size={12} strokeWidth={2.2} aria-hidden />
{t('journey.map.pick.openEntry')}
</button>
);
}
function PlaceDetail({ place, onOpenEntry }: { place: JourneyMapPlace; onOpenEntry?: (entryId: string) => void }) {
const { t, locale } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-1">
<SoftPill icon={<CalendarRange size={11} strokeWidth={2} />}>{t('journey.map.pick.planned')}</SoftPill>
<WhiteBadge>{place.trip_title}</WhiteBadge>
{place.days.map((day) => (
<WhiteBadge key={day}>{dayLabel(day, locale)}</WhiteBadge>
))}
{place.time && <WhiteBadge icon={<Clock size={10} strokeWidth={2} />}>{place.time}</WhiteBadge>}
</div>
{place.entry_id !== null ? (
<OpenEntry entryId={place.entry_id} onOpenEntry={onOpenEntry} />
) : (
<p className="text-content-muted" style={fs(11.5, 'body')}>
{t('journey.map.pick.notWritten')}
</p>
)}
</div>
);
}
function StayDetail({
stay,
onOpenEntry,
entryTitleOf,
}: { stay: JourneyMapStay } & Pick<Props, 'onOpenEntry' | 'entryTitleOf'>) {
const { t, locale } = useTranslation();
const entryTitle = entryTitleOf?.(stay.entry_id);
const from = timeLabel(stay.started_at, locale);
const to = timeLabel(stay.ended_at, locale);
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-1">
<SoftPill tone="info" icon={<MapPin size={11} strokeWidth={2} />}>
{t('journey.map.pick.stay')}
</SoftPill>
{from && (
<WhiteBadge icon={<Clock size={10} strokeWidth={2} />}>
{to ? t('journey.map.pick.timeRange', { from, to }) : from}
</WhiteBadge>
)}
</div>
{entryTitle && (
<p className="truncate text-content-secondary" style={fs(11.5, 'body')}>
{t('journey.map.pick.partOf', { title: entryTitle })}
</p>
)}
<OpenEntry entryId={stay.entry_id} onOpenEntry={onOpenEntry} />
</div>
);
}
function markName(mark: JourneyMapMark): string {
return mark.kind === 'plan' ? mark.place.name : mark.stay.name;
}
/**
* What a mark picked on the journey map is (JRN-04), docked over the map's
* corner: a planned place with its trip, its days and the entry written about
* it, or a stay with its times and the report it belongs to. Several marks on
* one spot come as a list first, each with what kind it is, so overlapping
* hits stay reachable one by one.
*/
export default function JourneyMapPickCard({ marks, onClose, onOpenEntry, entryTitleOf }: Props) {
const { t, locale } = useTranslation();
const [picked, setPicked] = useState<JourneyMapMark | null>(marks.length === 1 ? marks[0] : null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const title = picked ? markName(picked) : t('journey.map.pick.here');
return (
<div
role="dialog"
aria-label={title}
data-testid="journey-map-pick"
className="pointer-events-auto flex w-[280px] max-w-[calc(100%-24px)] flex-col gap-2 rounded-xl border border-edge bg-surface-card p-3 shadow-lg"
>
<div className="flex items-start gap-1.5">
{picked && marks.length > 1 && (
<button
type="button"
onClick={() => setPicked(null)}
aria-label={t('journey.map.pick.back')}
className="-ms-1 grid h-6 w-6 flex-none place-items-center rounded-md text-content-muted hover:bg-surface-hover"
>
<ChevronLeft size={14} strokeWidth={2.2} className="rtl:rotate-180" />
</button>
)}
<span className="min-w-0 flex-1 font-semibold text-content" style={fs(13, 'body')}>
{title}
</span>
{!picked && <WhiteBadge>{marks.length}</WhiteBadge>}
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="grid h-6 w-6 flex-none place-items-center rounded-md text-content-muted hover:bg-surface-hover"
>
<X size={14} strokeWidth={2.2} />
</button>
</div>
{picked?.kind === 'plan' && <PlaceDetail place={picked.place} onOpenEntry={onOpenEntry} />}
{picked?.kind === 'stay' && (
<StayDetail stay={picked.stay} onOpenEntry={onOpenEntry} entryTitleOf={entryTitleOf} />
)}
{!picked && (
<ul className="-mx-1 flex max-h-[220px] flex-col overflow-y-auto">
{marks.map((mark) => (
<li key={mark.key}>
<button
type="button"
onClick={() => setPicked(mark)}
className="flex w-full items-center gap-2 rounded-lg px-1.5 py-1.5 text-start hover:bg-surface-hover"
>
<span aria-hidden className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: mark.color }} />
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
{markName(mark)}
</span>
<SoftPill tone={mark.kind === 'plan' ? 'neutral' : 'info'}>
{mark.kind === 'plan' ? t('journey.map.pick.planned') : t('journey.map.pick.stay')}
</SoftPill>
{mark.kind === 'plan' && mark.place.days[0] && (
<WhiteBadge>{dayLabel(mark.place.days[0], locale)}</WhiteBadge>
)}
<ChevronRight size={13} strokeWidth={2} className="flex-none text-content-faint rtl:rotate-180" />
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,103 @@
// FE-JRN-RECORDING-DIALOG-001 to FE-JRN-RECORDING-DIALOG-005
//
// JRN-01: the desktop dialog that takes one day's recorded route over. It names
// the journey and the day, shows what would be kept, and keeps it only on its
// button.
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildRecordedDay, buildRecordingPreview } from '../../../tests/helpers/journeyRecording';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import JourneyRecordingDialog from './JourneyRecordingDialog';
const repo = vi.hoisted(() => ({ preview: vi.fn(), save: vi.fn(), remove: vi.fn() }));
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const httpError = (code: string) => Object.assign(new Error(code), { response: { data: { error: code, code } } });
function renderDialog(stored?: ReturnType<typeof buildRecordedDay>) {
const props = { onKept: vi.fn(), onRemoved: vi.fn(), onClose: vi.fn() };
render(<JourneyRecordingDialog journeyId={7} journeyTitle="Venice" date="2026-05-14" stored={stored} {...props} />);
return props;
}
beforeEach(() => {
for (const fn of [...Object.values(repo), ...Object.values(toast)]) fn.mockReset();
});
describe('JourneyRecordingDialog', () => {
it('FE-JRN-RECORDING-DIALOG-001: names the journey and the day and shows what the day covers before anything is kept', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview({ audience: 2 }));
renderDialog();
expect(await screen.findByTestId('recording-sketch')).toBeInTheDocument();
expect(screen.getByText('Venice')).toBeInTheDocument();
expect(screen.getByText(/May 14, 2026/)).toBeInTheDocument();
expect(screen.getByText('1.5 km')).toBeInTheDocument();
expect(screen.getByText('1 piece')).toBeInTheDocument();
expect(screen.getByText('Walking')).toBeInTheDocument();
expect(screen.getByText(/2 other people on this journey will see the route/)).toBeInTheDocument();
expect(repo.save).not.toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DIALOG-002: keeps the day on its button and closes', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview());
const day = buildRecordedDay();
repo.save.mockResolvedValue({ day, outcome: 'created' });
const props = renderDialog();
await userEvent.click(await screen.findByRole('button', { name: 'Keep route' }));
await waitFor(() => expect(props.onKept).toHaveBeenCalledWith(day));
expect(props.onClose).toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DIALOG-003: gaps and an unfinished day are said out loud, a kept twin cannot be kept again', async () => {
const preview = buildRecordingPreview();
repo.preview.mockResolvedValue({
...preview,
provisional: true,
stats: { ...preview.stats, gapCount: 2 },
stored: buildRecordedDay(),
sameAsStored: true,
});
renderDialog(buildRecordedDay());
expect(await screen.findByText('2 gaps')).toBeInTheDocument();
expect(screen.getByText(/not over yet in this time zone/)).toBeInTheDocument();
expect(screen.getByText('This journey keeps exactly this recording already.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Already kept' })).toBeDisabled();
});
it('FE-JRN-RECORDING-DIALOG-004: when Dawarich cannot be read it says why, that the kept route stays, and offers to try again', async () => {
repo.preview.mockRejectedValueOnce(httpError('unauthorized'));
renderDialog(buildRecordedDay());
expect(await screen.findByText(/Dawarich rejected the API key\./)).toBeInTheDocument();
expect(screen.getByText(/The route this journey keeps stays as it is\./)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Keep route' })).toBeDisabled();
repo.preview.mockResolvedValue(buildRecordingPreview());
await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(await screen.findByTestId('recording-sketch')).toBeInTheDocument();
});
it('FE-JRN-RECORDING-DIALOG-005: removing the kept route asks first, also when Dawarich does not answer', async () => {
repo.preview.mockRejectedValue(httpError('not_connected'));
repo.remove.mockResolvedValue(undefined);
const props = renderDialog(buildRecordedDay());
await screen.findByText(/No Dawarich instance is connected yet\./);
await userEvent.click(screen.getByRole('button', { name: 'Remove route' }));
expect(repo.remove).not.toHaveBeenCalled();
expect(await screen.findByText('Remove the kept route?')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Remove route' });
const confirm = buttons[buttons.length - 1];
if (!confirm) throw new Error('no confirm button');
await userEvent.click(confirm);
await waitFor(() => expect(props.onRemoved).toHaveBeenCalledWith('2026-05-14'));
});
});
@@ -0,0 +1,32 @@
import { useTranslation } from '../../i18n';
import DawarichDayDialog from './DawarichDayDialog';
import { JourneyRecordingActions, JourneyRecordingReview } from './JourneyRecordingReview';
import { useJourneyRecordingDay, type JourneyRecordingViewProps } from './useJourneyRecordingDay';
/**
* Taking one day's recorded route from Dawarich into the journey (JRN-01),
* on the desktop: the journey and the day in the head band, the preview in the
* body, keeping it only on the button that says so.
*/
export default function JourneyRecordingDialog({
journeyTitle,
date,
stored,
onClose,
...rest
}: JourneyRecordingViewProps) {
const { t } = useTranslation();
const day = useJourneyRecordingDay({ date, onClose, ...rest });
return (
<DawarichDayDialog
journeyTitle={day.preview?.journey.title ?? journeyTitle}
date={date}
sub={t('journey.recording.sub')}
onClose={onClose}
footer={<JourneyRecordingActions day={day} stored={stored} onCancel={onClose} />}
>
<JourneyRecordingReview day={day} stored={stored} />
</DawarichDayDialog>
);
}
@@ -0,0 +1,77 @@
import { RefreshCw } from 'lucide-react';
import type React from 'react';
import { useTranslation } from '../../i18n';
import ToggleSwitch from '../Settings/ToggleSwitch';
import { fs } from '../shared/DialogShell';
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep';
/**
* The owner's switch for preparing the recorded days (JRN-03) in the desktop
* journey settings, a row among the map switches. `rowClass` is the row look
* of the list it sits in.
*/
export function JourneyRecordingPrepRow({
on,
prep,
rowClass,
}: {
on: boolean;
prep: JourneyRecordingPrepState;
rowClass: string;
}): React.ReactElement {
const { t } = useTranslation();
return (
<div className={prep.saving ? `${rowClass} opacity-60` : rowClass}>
<span className="min-w-0 flex-1">
<span className="block text-content" style={fs(13, 'body')}>
{t('journey.settings.autoPrepare')}
</span>
<span className="block text-content-faint" style={fs(11.5)}>
{t('journey.settings.autoPrepareHint')}
</span>
</span>
<ToggleSwitch
on={on}
onToggle={() => {
if (!prep.saving) void prep.setEnabled(!on);
}}
label={t('journey.settings.autoPrepare')}
/>
</div>
);
}
/**
* Under the switches: how the caller's own preparation stands, in warning
* colour when something keeps it from working, with a fetch by hand where one
* would reach anything. Nothing while the switch is off.
*/
export function JourneyRecordingPrepStatus({ prep }: { prep: JourneyRecordingPrepState }): React.ReactElement | null {
const { t } = useTranslation();
const line = prep.statusLine;
if (!line) return null;
return (
<div className="mt-2 flex items-start gap-2" data-testid="journey-prep-status">
<p
className={`m-0 flex-1 leading-normal ${line.tone === 'warning' ? 'text-warning' : 'text-content-faint'}`}
style={fs(11.5)}
>
{line.text}
</p>
{prep.canFetch && (
<button
type="button"
onClick={() => {
void prep.fetchNow();
}}
disabled={prep.fetching}
className="inline-flex flex-none items-center gap-1 rounded-full bg-surface-card px-2.5 py-1 font-semibold text-content-secondary ring-1 ring-edge-faint hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
style={fs(11.5)}
>
<RefreshCw size={12} className={prep.fetching ? 'animate-spin' : undefined} aria-hidden="true" />
{prep.fetching ? t('journey.prep.fetching') : t('journey.prep.fetchNow')}
</button>
)}
</div>
);
}
@@ -0,0 +1,286 @@
import type { JourneyRecordedDay, JourneyRecordingPreview } from '@trek/shared';
import { AlertTriangle, Clock, Info, Loader2, RefreshCw, Route, Users } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance } from '../../utils/units';
import { SoftPill } from '../Planner/planParts';
import ConfirmDialog from '../shared/ConfirmDialog';
import CustomSelect from '../shared/CustomSelect';
import { PANEL } from '../shared/dialogParts';
import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { modeLabel, sketchPaths } from './journeyRecordingModel';
import type { useJourneyRecordingDay } from './useJourneyRecordingDay';
type RecordingDayState = ReturnType<typeof useJourneyRecordingDay>;
const SKETCH_W = 320;
const SKETCH_H = 150;
/** A sentence about the recording, its icon saying how much it matters. */
export function Notice({ tone, children }: { tone: 'info' | 'warning' | 'danger'; children: ReactNode }) {
const look = {
info: 'bg-surface-secondary text-content-secondary',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
}[tone];
const Icon = tone === 'info' ? Info : AlertTriangle;
return (
<p className={`m-0 flex items-start gap-2 rounded-[12px] px-3 py-2.5 ${look}`} style={fs(12.5, 'body')}>
<Icon size={15} strokeWidth={2} className="mt-px flex-none" aria-hidden="true" />
<span className="min-w-0">{children}</span>
</p>
);
}
/** The day's pieces drawn into a box, each its own line, so a gap stays a gap. */
function RecordingSketch({ segments }: { segments: JourneyRecordingPreview['segments'] }) {
const { paths, ends } = sketchPaths(segments, SKETCH_W, SKETCH_H);
return (
<svg
viewBox={`0 0 ${SKETCH_W} ${SKETCH_H}`}
className="block h-[150px] w-full"
role="img"
aria-hidden="true"
data-testid="recording-sketch"
>
{paths.map((d, i) => (
<path
key={i}
d={d}
fill="none"
stroke="var(--accent)"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
{ends.map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={3} fill="var(--bg-card)" stroke="var(--text-faint)" strokeWidth={1.5} />
))}
</svg>
);
}
/** A time of the recording on the clock of the zone the day is cut in. */
function clockIn(iso: string, zoneMinutes: number, locale: string, is12h: boolean): string {
return new Date(Date.parse(iso) + zoneMinutes * 60_000).toLocaleTimeString(locale, {
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
hour12: is12h,
});
}
/** The day in figures: the sketch, how far, when, in how many pieces and gaps, how and from what. */
function RecordingFacts({ preview }: { preview: JourneyRecordingPreview }) {
const { t, locale } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
const { stats } = preview;
return (
<div className={PANEL}>
<RecordingSketch segments={preview.segments} />
<div className="flex flex-wrap items-center gap-1.5">
<SoftPill icon={<Route size={11} aria-hidden="true" />}>
{formatDistance(stats.distanceMeters / 1000, unit)}
</SoftPill>
{stats.firstAt && stats.lastAt && (
<SoftPill icon={<Clock size={11} aria-hidden="true" />}>
{t('journey.recording.span', {
from: clockIn(stats.firstAt, preview.utcOffsetMinutes, locale, is12h),
to: clockIn(stats.lastAt, preview.utcOffsetMinutes, locale, is12h),
})}
</SoftPill>
)}
<SoftPill>{t('journey.recording.pieces', { count: stats.segmentCount })}</SoftPill>
{stats.gapCount > 0 && (
<Tooltip label={t('journey.recording.gapsHint')} placement="top">
<button type="button" className="inline-flex cursor-default">
<SoftPill tone="info">{t('journey.recording.gaps', { count: stats.gapCount })}</SoftPill>
</button>
</Tooltip>
)}
{stats.modes.map((mode) => (
<SoftPill key={mode}>{modeLabel(mode, t)}</SoftPill>
))}
<Tooltip label={t('journey.recording.sourceHint')} placement="top">
<button type="button" className="inline-flex cursor-default">
<SoftPill>
{t(preview.sourceKind === 'tracks' ? 'journey.recording.sourceTracks' : 'journey.recording.sourcePoints')}
</SoftPill>
</button>
</Tooltip>
</div>
</div>
);
}
/** Every reason to look twice before keeping the day, and who will see it. */
function RecordingNotices({
preview,
changedSincePreview,
}: {
preview: JourneyRecordingPreview;
changedSincePreview: boolean;
}) {
const { t, locale } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
const { stored } = preview;
return (
<>
{changedSincePreview && <Notice tone="warning">{t('journey.recording.changedSincePreview')}</Notice>}
{preview.truncated && <Notice tone="warning">{t('journey.recording.truncated')}</Notice>}
{preview.provisional && <Notice tone="warning">{t('journey.recording.provisional')}</Notice>}
{preview.sameAsStored && <Notice tone="info">{t('journey.recording.same')}</Notice>}
{stored && !preview.sameAsStored && (
<Notice tone="info">
{t('journey.recording.differs', {
date: new Date(stored.savedAt).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
distance: formatDistance(stored.stats.distanceMeters / 1000, unit),
})}
</Notice>
)}
{preview.audience > 0 && (
<p className="m-0 flex items-start gap-2 text-content-muted" style={fs(12, 'body')}>
<Users size={14} className="mt-px flex-none" aria-hidden="true" />
{t('journey.recording.audience', { count: preview.audience })}
</p>
)}
</>
);
}
/** Reading, failed, nothing recorded, or the day to look at. */
function RecordingState({ day, stored }: { day: RecordingDayState; stored: JourneyRecordedDay | undefined }) {
const { t } = useTranslation();
const { preview } = day;
if (day.loading) {
return (
<p className="m-0 flex items-center gap-2 py-6 text-content-muted" role="status" style={fs(12.5, 'body')}>
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
{t('journey.recording.loading')}
</p>
);
}
if (day.error) {
return (
<div className="flex flex-col gap-2">
<Notice tone="danger">
{day.error}
{stored && ` ${t('journey.recording.keptStays')}`}
</Notice>
<span>
<DialogButton onClick={day.retry} icon={<RefreshCw size={14} aria-hidden="true" />}>
{t('journey.recording.retry')}
</DialogButton>
</span>
</div>
);
}
if (!preview) return null;
if (preview.contentHash === null) return <Notice tone="info">{t('journey.recording.empty')}</Notice>;
return (
<div className="flex flex-col gap-3">
<RecordingFacts preview={preview} />
<RecordingNotices preview={preview} changedSincePreview={day.changedSincePreview} />
</div>
);
}
/**
* What taking a day over would keep (JRN-01): the zone the day is cut in, the
* recording as a sketch and in figures, and every reason to look twice. The
* body of the desktop dialog and of the phone sheet alike.
*/
export function JourneyRecordingReview({
day,
stored,
}: {
day: RecordingDayState;
/** What the journey keeps for the day, known even when Dawarich does not answer. */
stored: JourneyRecordedDay | undefined;
}) {
const { t } = useTranslation();
return (
<>
<DialogSection label={t('journey.recording.zone')}>
<CustomSelect
ariaLabel={t('journey.recording.zone')}
value={day.zone}
onChange={(value) => day.chooseZone(Number(value))}
options={day.zoneOptions}
disabled={day.loading}
/>
<p className="m-0 mt-1.5 text-content-faint" style={fs(11.5)}>
{t('journey.recording.zoneHint')}
</p>
</DialogSection>
<DialogSection label={t('journey.recording.scope')}>
<RecordingState day={day} stored={stored ?? day.preview?.stored ?? undefined} />
</DialogSection>
</>
);
}
/**
* The buttons under the review: drop the kept route (asked first), close, and
* the one that keeps, replaces or says the day is kept already.
*/
export function JourneyRecordingActions({
day,
stored,
onCancel,
}: {
day: RecordingDayState;
stored: JourneyRecordedDay | undefined;
onCancel: () => void;
}) {
const { action, busy } = day;
const hasStored = Boolean(stored ?? day.preview?.stored);
const onSave = () => void day.save();
const onRemove = () => void day.remove();
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const primary =
action === 'replace'
? t('journey.recording.replace')
: action === 'same'
? t('journey.recording.alreadyKept')
: t('journey.recording.keep');
const canSave = (action === 'keep' || action === 'replace') && busy === null;
return (
<>
{hasStored && (
<DeleteButton
onClick={() => setConfirming(true)}
label={t('journey.recording.remove')}
disabled={busy !== null}
/>
)}
<FooterSpacer />
<DialogButton onClick={onCancel}>{t('common.cancel')}</DialogButton>
<DialogButton
variant="primary"
onClick={onSave}
disabled={!canSave}
icon={busy === 'save' ? <Loader2 size={14} className="animate-spin" aria-hidden="true" /> : undefined}
>
{primary}
</DialogButton>
<ConfirmDialog
isOpen={confirming}
onClose={() => setConfirming(false)}
onConfirm={() => {
setConfirming(false);
onRemove();
}}
title={t('journey.recording.removeTitle')}
message={t('journey.recording.removeMessage')}
confirmLabel={t('journey.recording.remove')}
/>
</>
);
}
@@ -1,4 +1,4 @@
// FE-JRN-SHARE-001 to FE-JRN-SHARE-011 // FE-JRN-SHARE-001 to FE-JRN-SHARE-014
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw' import { http, HttpResponse, delay } from 'msw'
@@ -214,4 +214,35 @@ describe('JourneyShareSection', () => {
// The temporary textarea is removed again. // The temporary textarea is removed again.
expect(document.querySelector('textarea')).toBeNull() expect(document.querySelector('textarea')).toBeNull()
}) })
it('FE-JRN-SHARE-013: the trip plan and the recorded routes switch on under the map, each on its own (JRN-04)', async () => {
const bodies: Record<string, unknown>[] = []
server.use(
http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })),
http.post('/api/journeys/7/share-link', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ token: 'tok-abc' })
}),
)
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
const plan = await screen.findByRole('button', { name: /Trip plan/ })
expect(plan).toHaveAttribute('aria-pressed', 'false')
act(() => { plan.click() })
await waitFor(() => expect(bodies).toHaveLength(1))
expect(bodies[0]).toMatchObject({ share_plan: true })
expect(bodies[0]).not.toHaveProperty('share_recordings')
expect(screen.getByRole('button', { name: /Trip plan/ })).toHaveAttribute('aria-pressed', 'true')
})
it('FE-JRN-SHARE-014: without the map shared the map extras are dimmed and cannot be switched', async () => {
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink({ share_map: false, share_plan: true }) })))
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
const plan = await screen.findByRole('button', { name: /Trip plan/ })
expect(plan).toBeDisabled()
expect(plan).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: /Recorded routes/ })).toBeDisabled()
})
}) })
@@ -1,6 +1,48 @@
import { Link, List, Grid, MapPin, Check } from 'lucide-react' import { Link, List, Grid, MapPin, Check, CalendarRange, Route } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useJourneyShareLink } from './useJourneyShareLink' import { fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import { SHARE_MAP_EXTRAS, useJourneyShareLink, type JourneyShareLink, type JourneyShareMapExtra } from './useJourneyShareLink'
const EXTRA_ICONS = { share_plan: CalendarRange, share_recordings: Route } as const
/**
* What the shared map shows besides the entries (JRN-04): the trips' plan and
* the recorded days, each off until the owner switches it on, nested under the
* map and dimmed with the reason while the map is not shared.
*/
function ShareMapExtras({ link, onToggle }: { link: JourneyShareLink; onToggle: (key: JourneyShareMapExtra) => void }) {
const { t } = useTranslation()
const mapShared = !!link.share_map
return (
<div className="ms-5 flex flex-col gap-1.5 border-s border-edge ps-3">
{SHARE_MAP_EXTRAS.map(({ key, labelKey, hintKey }) => {
const Icon = EXTRA_ICONS[key]
const on = mapShared && !!link[key]
return (
<Tooltip key={key} label={mapShared ? '' : t('journey.share.needsMap')} placement="top">
<button
type="button"
aria-pressed={on}
disabled={!mapShared}
onClick={() => onToggle(key)}
className={`flex w-full items-start gap-2.5 rounded-xl border px-3 py-2 text-start transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
on ? 'border-accent bg-accent text-accent-text' : 'border-edge text-content-muted hover:border-content-faint'
}`}
>
<Icon size={13} className="mt-0.5 flex-none" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-medium" style={fs(12, 'body')}>{t(labelKey)}</span>
<span className={on ? 'text-accent-text opacity-80' : 'text-content-faint'} style={fs(11)}>{t(hintKey)}</span>
</span>
{on && <Check size={12} className="mt-0.5 flex-none" />}
</button>
</Tooltip>
)
})}
</div>
)
}
export default function JourneyShareSection({ journeyId }: { journeyId: number }) { export default function JourneyShareSection({ journeyId }: { journeyId: number }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -61,6 +103,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
{link[key] && <Check size={12} className="ms-auto" />} {link[key] && <Check size={12} className="ms-auto" />}
</button> </button>
))} ))}
<ShareMapExtras link={link} onToggle={key => void togglePerm(key)} />
</div> </div>
</div> </div>
)} )}
@@ -1,6 +1,8 @@
import { useRef, useEffect } from 'react' import { useRef, useEffect } from 'react'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap' import JourneyLayeredMap from './JourneyLayeredMap'
import type { JourneyMapLayersView } from './useJourneyMapLayers'
import type { MapPhoto } from './journeyPhotoLayer'
import JourneyEntryCover from './JourneyEntryCover' import JourneyEntryCover from './JourneyEntryCover'
import JourneyDayScrubber from './JourneyDayScrubber' import JourneyDayScrubber from './JourneyDayScrubber'
import { dayColorOf } from './journeyCard' import { dayColorOf } from './journeyCard'
@@ -35,6 +37,14 @@ interface Props {
showWeather?: boolean showWeather?: boolean
/** Which entry to open on. Today's, when today is part of the journey (#2299). */ /** Which entry to open on. Today's, when today is part of the journey (#2299). */
initialEntryId?: string | null initialEntryId?: string | null
/** The map's layers (JRN-04); a picked place or stay leads to its entry's card. */
layers?: JourneyMapLayersView | null
entryTitleOf?: (entryId: number) => string | null
/** Geotagged photos on the map, a tap opening them. */
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
/** Where the layer switches sit, below a bar laid over the top of the map. */
controlsTop?: number | string
} }
export default function MobileMapTimeline({ export default function MobileMapTimeline({
@@ -52,6 +62,11 @@ export default function MobileMapTimeline({
showMood = true, showMood = true,
showWeather = true, showWeather = true,
initialEntryId, initialEntryId,
layers,
entryTitleOf,
photos,
onPhotoClick,
controlsTop,
}: Props) { }: Props) {
const { const {
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays, mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
@@ -96,12 +111,20 @@ export default function MobileMapTimeline({
className="fixed inset-x-0 z-10" className="fixed inset-x-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }} style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
> >
<JourneyMap <JourneyLayeredMap
ref={mapRef} ref={mapRef}
engine="leaflet"
layers={layers}
compactControls
controlsTop={controlsTop}
onOpenEntry={handleMarkerClick}
entryTitleOf={entryTitleOf}
entries={mapEntries} entries={mapEntries}
checkins={[]} checkins={[]}
trail={trail} trail={trail}
tracks={tracks} tracks={tracks}
photos={photos}
onPhotoClick={onPhotoClick}
height={9999} height={9999}
dark={dark} dark={dark}
onMarkerClick={handleMarkerClick} onMarkerClick={handleMarkerClick}
@@ -128,12 +151,20 @@ export default function MobileMapTimeline({
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }} style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
> >
{/* Full-screen map */} {/* Full-screen map */}
<JourneyMap <JourneyLayeredMap
ref={mapRef} ref={mapRef}
engine="leaflet"
layers={layers}
compactControls
controlsTop={controlsTop}
onOpenEntry={handleMarkerClick}
entryTitleOf={entryTitleOf}
entries={mapEntries} entries={mapEntries}
checkins={[]} checkins={[]}
trail={trail} trail={trail}
tracks={tracks} tracks={tracks}
photos={photos}
onPhotoClick={onPhotoClick}
height={9999} height={9999}
dark={dark} dark={dark}
activeMarkerId={activeEntryId} activeMarkerId={activeEntryId}
@@ -0,0 +1,168 @@
// FE-JRN-DAYIMPORT-MODEL-001 to FE-JRN-DAYIMPORT-MODEL-013
//
// JRN-02: what the traveller decided in the day take-over, before anything is
// written. Two stays at San Marco, a parking stop and Rialto, as the review
// lists them.
import type { JourneyDayStay } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { buildDayStay, buildDayStays } from '../../../tests/helpers/journeyRecording';
import {
canJoin,
defaultAnchor,
groupsOf,
initialChoice,
problemOf,
rename,
REPORT_KEY,
requestOf,
titleOf,
toggleJoin,
toggleStay,
type DayImportChoice,
} from './journeyDayImportModel';
const STAYS = buildDayStays().stays;
const [PARKING, MARKUS, , RIALTO] = STAYS as [JourneyDayStay, JourneyDayStay, JourneyDayStay, JourneyDayStay];
const REPORT = 'Day of 14 May';
describe('journeyDayImportModel', () => {
it('FE-JRN-DAYIMPORT-MODEL-001: every open stay starts chosen, and the same place twice in a row starts joined', () => {
const choice = initialChoice(STAYS);
expect([...choice.selected]).toEqual([10, 11, 12, 13]);
expect([...choice.joined]).toEqual([12]);
expect(groupsOf(choice, STAYS).map((g) => [g.kind, g.stays.map((s) => s.suggestionId)])).toEqual([
['single', [10]],
['merged', [11, 12]],
['single', [13]],
]);
});
it('FE-JRN-DAYIMPORT-MODEL-002: stays the journal holds or that went elsewhere are never chosen; one left out before starts unchosen', () => {
const stays = [
buildDayStay({ suggestionId: 1, status: 'here', entry: { id: 4, title: 'Morning' } }),
buildDayStay({ suggestionId: 2, status: 'elsewhere', name: 'Rialto' }),
buildDayStay({ suggestionId: 3, status: 'skipped', name: 'Lido' }),
];
const choice = initialChoice(stays);
expect(choice.selected.size).toBe(0);
expect(problemOf(choice, stays, REPORT)).toBe('nothing');
const picked = toggleStay(choice, 3);
expect(groupsOf(picked, stays).map((g) => g.key)).toEqual([3]);
expect(requestOf(picked, stays, REPORT)).toEqual({ groups: [{ kind: 'single', stays: [{ suggestionId: 3 }] }] });
});
it('FE-JRN-DAYIMPORT-MODEL-003: an open stay left unchosen is sent as left out, and a renamed one under its new name', () => {
let choice = toggleStay(initialChoice(STAYS), 10);
choice = rename(choice, RIALTO, ' Ponte di Rialto ');
expect(requestOf(choice, STAYS, REPORT)).toEqual({
groups: [
{
kind: 'merged',
stays: [{ suggestionId: 11 }, { suggestionId: 12 }],
title: 'Piazza San Marco',
anchorSuggestionId: 12,
},
{ kind: 'single', stays: [{ suggestionId: 13, name: 'Ponte di Rialto' }] },
],
skipSuggestionIds: [10],
});
});
it('FE-JRN-DAYIMPORT-MODEL-004: joining needs both neighbours chosen; unchoosing a stay separates it', () => {
const choice = toggleStay(initialChoice(STAYS), 11);
expect(canJoin(choice, STAYS, 2)).toBe(false);
expect(groupsOf(choice, STAYS).map((g) => g.kind)).toEqual(['single', 'single', 'single']);
const back = toggleStay(choice, 11);
expect(back.joined.has(12)).toBe(true);
expect(canJoin(back, STAYS, 1)).toBe(true);
const joined = toggleJoin(back, 11);
expect(groupsOf(joined, STAYS).map((g) => g.stays.length)).toEqual([3, 1]);
});
it('FE-JRN-DAYIMPORT-MODEL-005: a day report takes every chosen stay, titled by default, at the longest stay or off the map', () => {
const choice = { ...initialChoice(STAYS), mode: 'report' as const };
const [report] = groupsOf(choice, STAYS);
if (!report) throw new Error('no report');
expect(report).toMatchObject({ key: REPORT_KEY, kind: 'report' });
expect(report.stays).toHaveLength(4);
expect(titleOf(choice, report, REPORT)).toBe(REPORT);
expect(requestOf(choice, STAYS, REPORT).groups[0]).toMatchObject({ title: REPORT, anchorSuggestionId: 12 });
const offMap = { ...choice, anchors: { [REPORT_KEY]: null } };
expect(requestOf(offMap, STAYS, REPORT).groups[0]).toMatchObject({ anchorSuggestionId: null });
});
it('FE-JRN-DAYIMPORT-MODEL-006: a report of one chosen stay is that stay on its own', () => {
let choice: DayImportChoice = { ...initialChoice(STAYS), mode: 'report' };
for (const id of [10, 11, 12]) choice = toggleStay(choice, id);
expect(groupsOf(choice, STAYS)).toEqual([{ key: 13, kind: 'single', stays: [RIALTO] }]);
});
it('FE-JRN-DAYIMPORT-MODEL-007: a blank name or a blank title holds the take-over back, so no card is empty', () => {
const choice = initialChoice(STAYS);
expect(problemOf(choice, STAYS, REPORT)).toBeNull();
expect(problemOf(rename(choice, PARKING, ' '), STAYS, REPORT)).toBe('name');
expect(problemOf({ ...choice, titles: { 11: ' ' } }, STAYS, REPORT)).toBe('title');
expect(problemOf({ ...choice, mode: 'report', titles: { [REPORT_KEY]: '' } }, STAYS, REPORT)).toBe('title');
});
it('FE-JRN-DAYIMPORT-MODEL-008: the default position is the longest stay that has a place', () => {
expect(defaultAnchor(STAYS)).toBe(12);
expect(defaultAnchor([buildDayStay({ suggestionId: 5, lat: null, lng: null })])).toBeNull();
expect(rename(rename(initialChoice(STAYS), MARKUS, 'X'), MARKUS, MARKUS.name).names).toEqual({});
});
it('FE-JRN-DAYIMPORT-MODEL-009: a picked position whose stay was unchosen or split off falls back to one in the group', () => {
const report = { ...initialChoice(STAYS), mode: 'report' as const, anchors: { [REPORT_KEY]: 10 } };
expect(requestOf(toggleStay(report, 10), STAYS, REPORT).groups[0]).toMatchObject({ anchorSuggestionId: 12 });
const three = { ...toggleJoin(initialChoice(STAYS), 13), anchors: { 11: 13 } };
expect(requestOf(three, STAYS, REPORT).groups[1]).toMatchObject({ kind: 'merged', anchorSuggestionId: 13 });
expect(requestOf(toggleJoin(three, 13), STAYS, REPORT).groups[1]).toMatchObject({
kind: 'merged',
anchorSuggestionId: 12,
});
});
it('FE-JRN-DAYIMPORT-MODEL-010: the same place twice without a position is joined and sent without a pin', () => {
const stays = [
buildDayStay({ suggestionId: 1, lat: null, lng: null }),
buildDayStay({ suggestionId: 2, lat: null, lng: null }),
];
const choice = initialChoice(stays);
expect(problemOf(choice, stays, REPORT)).toBeNull();
expect(requestOf(choice, stays, REPORT)).toEqual({
groups: [{ kind: 'merged', stays: [{ suggestionId: 1 }, { suggestionId: 2 }], title: 'Piazza San Marco' }],
});
});
it('FE-JRN-DAYIMPORT-MODEL-011: a stay taken over meanwhile is never joined across, even while still ticked', () => {
const stays = [
buildDayStay({ suggestionId: 1, name: 'Lido' }),
buildDayStay({ suggestionId: 2, name: 'Rialto', status: 'here' }),
buildDayStay({ suggestionId: 3, name: 'Zattere' }),
];
const choice = { ...initialChoice(stays), selected: new Set([1, 2, 3]), joined: new Set([3]) };
expect(canJoin(choice, stays, 2)).toBe(false);
expect(groupsOf(choice, stays).map((g) => g.stays.map((s) => s.suggestionId))).toEqual([[1], [3]]);
});
it('FE-JRN-DAYIMPORT-MODEL-012: a stay Dawarich itself doubts starts unchosen, unless it was confirmed there', () => {
const stays = [
buildDayStay({ suggestionId: 1, name: 'Car park', confidenceBand: 'low', sourceStatus: 'suggested' }),
buildDayStay({ suggestionId: 2, name: 'Bridge', confidenceBand: 'low', sourceStatus: 'confirmed' }),
buildDayStay({ suggestionId: 3, name: 'Cathedral', confidenceBand: 'medium', sourceStatus: 'suggested' }),
];
expect([...initialChoice(stays).selected]).toEqual([2, 3]);
});
it('FE-JRN-DAYIMPORT-MODEL-013: a night at the hotel is the position only when no stay of the day has one', () => {
const night = buildDayStay({
suggestionId: 1,
startedAt: '2026-05-14T19:00:00+02:00',
endedAt: '2026-05-15T08:00:00+02:00',
durationMinutes: 780,
});
const sight = buildDayStay({ suggestionId: 2, durationMinutes: 50 });
expect(defaultAnchor([sight, night])).toBe(2);
expect(defaultAnchor([night, buildDayStay({ suggestionId: 3, lat: null, lng: null })])).toBe(1);
});
});
@@ -0,0 +1,201 @@
import type { JourneyDayImportRequest, JourneyDayStay } from '@trek/shared';
/**
* What the traveller decided in the day take-over (JRN-02), before anything is
* written: which stays, under which names, which of them belong together, and
* whether the day becomes one report. Pure, so the dialog and the phone sheet
* share it and the tests need no rendering.
*/
export interface DayImportChoice {
selected: ReadonlySet<number>;
/** Names as corrected, by suggestion id; a stay not in here keeps its recorded name. */
names: Readonly<Record<number, string>>;
/** Stays joined to the stay listed right before them. */
joined: ReadonlySet<number>;
mode: 'entries' | 'report';
/** Titles as typed, by the key of the group they head. */
titles: Readonly<Record<number, string>>;
/** The stay a group is pinned to, by group key; null keeps a report off the map. */
anchors: Readonly<Record<number, number | null>>;
}
export interface DayImportGroup {
/** The first stay's id, or REPORT_KEY for the day report. */
key: number;
kind: 'single' | 'merged' | 'report';
stays: JourneyDayStay[];
}
export const REPORT_KEY = 0;
/** Two stays this close under the same name are one place visited twice. */
const SAME_PLACE_METERS = 200;
export function isTakeable(stay: JourneyDayStay): boolean {
return stay.status === 'open' || stay.status === 'skipped';
}
/**
* Every open stay chosen but those Dawarich itself doubts (the car park passed
* on the way, more often than not), and the same place twice in a row already
* joined: the square somebody left for a coffee and came back to is one memory.
*/
export function initialChoice(stays: JourneyDayStay[]): DayImportChoice {
const selected = new Set(
stays
.filter((stay) => stay.status === 'open' && !(stay.confidenceBand === 'low' && stay.sourceStatus !== 'confirmed'))
.map((stay) => stay.suggestionId)
);
const joined = new Set<number>();
stays.forEach((stay, index) => {
const before = stays[index - 1];
if (before && selected.has(stay.suggestionId) && selected.has(before.suggestionId) && samePlace(before, stay)) {
joined.add(stay.suggestionId);
}
});
return { selected, names: {}, joined, mode: 'entries', titles: {}, anchors: {} };
}
function samePlace(a: JourneyDayStay, b: JourneyDayStay): boolean {
if (a.name.trim().toLowerCase() !== b.name.trim().toLowerCase()) return false;
if (a.lat === null || a.lng === null || b.lat === null || b.lng === null) return true;
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
return 2 * 6_371_000 * Math.asin(Math.sqrt(h)) <= SAME_PLACE_METERS;
}
export function nameOf(choice: DayImportChoice, stay: JourneyDayStay): string {
return choice.names[stay.suggestionId] ?? stay.name;
}
/** Whether a stay can be joined to the one listed before it: both chosen and both free to take. */
export function canJoin(choice: DayImportChoice, stays: JourneyDayStay[], index: number): boolean {
const stay = stays[index];
const before = stays[index - 1];
return (
choice.mode === 'entries' &&
!!stay &&
!!before &&
isTakeable(stay) &&
isTakeable(before) &&
choice.selected.has(stay.suggestionId) &&
choice.selected.has(before.suggestionId)
);
}
/** The entries the choice writes, in the order the day was lived. */
export function groupsOf(choice: DayImportChoice, stays: JourneyDayStay[]): DayImportGroup[] {
const chosen = stays.filter((stay) => isTakeable(stay) && choice.selected.has(stay.suggestionId));
if (choice.mode === 'report' && chosen.length > 1) return [{ key: REPORT_KEY, kind: 'report', stays: chosen }];
const groups: DayImportGroup[] = [];
stays.forEach((stay, index) => {
if (!chosen.includes(stay)) return;
const last = groups[groups.length - 1];
if (last && choice.joined.has(stay.suggestionId) && canJoin(choice, stays, index)) {
last.stays.push(stay);
last.kind = 'merged';
} else {
groups.push({ key: stay.suggestionId, kind: 'single', stays: [stay] });
}
});
return groups;
}
/**
* The longest stay that has a place: where a group sits on the map unless
* somebody picks another. A night somewhere outlasts any sight of the day, so
* a stay that ends on the same day goes first and the hotel only when there is
* nothing else.
*/
export function defaultAnchor(stays: JourneyDayStay[]): number | null {
const placed = stays.filter((stay) => stay.lat !== null && stay.lng !== null);
const sameDay = placed.filter((stay) => stay.startedAt.slice(0, 10) === stay.endedAt.slice(0, 10));
let best: JourneyDayStay | null = null;
for (const stay of sameDay.length > 0 ? sameDay : placed) {
if (!best || stay.durationMinutes > best.durationMinutes) best = stay;
}
return best?.suggestionId ?? null;
}
/** The picked stay while it is still in the group; one unchosen or split off since falls back to the default. */
export function anchorOf(choice: DayImportChoice, group: DayImportGroup): number | null {
const picked = choice.anchors[group.key];
if (picked === undefined) return defaultAnchor(group.stays);
if (picked !== null && !group.stays.some((stay) => stay.suggestionId === picked)) return defaultAnchor(group.stays);
return picked;
}
/** A group's title as typed, else what it starts with: the place for a merged entry, the given default for a report. */
export function titleOf(choice: DayImportChoice, group: DayImportGroup, reportDefault: string): string {
const typed = choice.titles[group.key];
if (typed !== undefined) return typed;
if (group.kind === 'report') return reportDefault;
const anchor = group.stays.find((stay) => stay.suggestionId === anchorOf(choice, group)) ?? group.stays[0];
return anchor ? nameOf(choice, anchor) : '';
}
/** Why the choice cannot be taken over yet, or null. A blank name or title would write an empty card. */
export function problemOf(
choice: DayImportChoice,
stays: JourneyDayStay[],
reportDefault: string
): 'nothing' | 'name' | 'title' | null {
const groups = groupsOf(choice, stays);
if (groups.length === 0) return 'nothing';
if (groups.some((group) => group.stays.some((stay) => !nameOf(choice, stay).trim()))) return 'name';
if (groups.some((group) => group.kind !== 'single' && !titleOf(choice, group, reportDefault).trim())) return 'title';
return null;
}
/** The take-over as the server reads it; open stays left unchosen are sent as left out. */
export function requestOf(
choice: DayImportChoice,
stays: JourneyDayStay[],
reportDefault: string
): JourneyDayImportRequest {
const groups = groupsOf(choice, stays).map((group) => {
const named = group.stays.map((stay) => {
const name = nameOf(choice, stay).trim();
return name === stay.name ? { suggestionId: stay.suggestionId } : { suggestionId: stay.suggestionId, name };
});
if (group.kind === 'single') return { kind: group.kind, stays: named };
const title = titleOf(choice, group, reportDefault).trim();
const anchor = anchorOf(choice, group);
// A merged entry of stays without a position has nothing to pin; the server then writes it without one.
if (anchor === null && group.kind === 'merged') return { kind: group.kind, stays: named, title };
return { kind: group.kind, stays: named, title, anchorSuggestionId: anchor };
});
const skipSuggestionIds = stays
.filter((stay) => stay.status === 'open' && !choice.selected.has(stay.suggestionId))
.map((stay) => stay.suggestionId);
return skipSuggestionIds.length > 0 ? { groups, skipSuggestionIds } : { groups };
}
export function toggleStay(choice: DayImportChoice, id: number): DayImportChoice {
const selected = new Set(choice.selected);
const joined = new Set(choice.joined);
if (selected.has(id)) {
selected.delete(id);
joined.delete(id);
} else {
selected.add(id);
}
return { ...choice, selected, joined };
}
export function toggleJoin(choice: DayImportChoice, id: number): DayImportChoice {
const joined = new Set(choice.joined);
if (joined.has(id)) joined.delete(id);
else joined.add(id);
return { ...choice, joined };
}
export function rename(choice: DayImportChoice, stay: JourneyDayStay, name: string): DayImportChoice {
const names = { ...choice.names };
if (name === stay.name) delete names[stay.suggestionId];
else names[stay.suggestionId] = name;
return { ...choice, names };
}
@@ -0,0 +1,67 @@
import L from 'leaflet';
import type { JourneyMapLayerVisibility } from './journeyMapLayers';
import { MARK_CLUSTER_PX, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks';
/** The layers of the Leaflet journey map a switch takes off and puts back (JRN-04). */
export type LeafletLayerKind = keyof JourneyMapLayerVisibility;
export type DrawnLayers = Record<LeafletLayerKind, L.Layer[]>;
export const NOTHING_DRAWN = (): DrawnLayers => ({ plan: [], recorded: [], entries: [] });
/**
* Puts each layer whose switch changed back on the map or takes it off,
* exactly as it was drawn, and answers what is shown now.
*/
export function switchLeafletLayers(
map: L.Map | null,
drawn: DrawnLayers,
shown: JourneyMapLayerVisibility,
next: JourneyMapLayerVisibility
): JourneyMapLayerVisibility {
if (!map) return shown;
for (const kind of Object.keys(drawn) as LeafletLayerKind[]) {
if (shown[kind] === next[kind]) continue;
for (const layer of drawn[kind]) {
if (next[kind]) layer.addTo(map);
else layer.remove();
}
}
return next;
}
/**
* The plan's places and the stays as one layer: below the entry pins, above
* the photo thumbnails, joined only where they would overlap on screen.
* Null while there is nothing to draw or the map has no view yet.
*/
export function drawLeafletMarks(
map: L.Map,
marks: readonly JourneyMapMark[] | undefined,
onPick: (members: JourneyMapMark[]) => void
): L.LayerGroup | null {
if (!marks?.length) return null;
try {
map.getCenter();
} catch {
// No view yet: the moveend after the first fit draws them.
return null;
}
const group = L.layerGroup();
for (const hit of clusterPoints(marks, (lat, lng) => map.latLngToContainerPoint([lat, lng]), MARK_CLUSTER_PX)) {
const look = markHitSvg(hit.members);
const marker = L.marker([hit.lat, hit.lng], {
icon: L.divIcon({
className: '',
iconSize: [look.width, look.height],
iconAnchor: [look.width / 2, look.anchor === 'bottom' ? look.height : look.height / 2],
html: look.html,
}),
zIndexOffset: -200,
keyboard: false,
});
marker.on('click', () => onPick(hit.members));
group.addLayer(marker);
}
group.addTo(map);
return group;
}
@@ -0,0 +1,189 @@
// FE-JOURNEY-MAPLAYERS-001 to FE-JOURNEY-MAPLAYERS-009
//
// JRN-04: the journey map's layer switches as kept in this browser, the marks
// the plan and the stays become, and the switches a reader is offered.
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ALL_VISIBLE,
buildControls,
buildMarks,
layersStorageKey,
parseStoredLayers,
readStoredLayers,
sourcesOf,
stateOf,
writeStoredLayers,
} from './journeyMapLayers';
const place = (over: Partial<JourneyMapPlace> = {}): JourneyMapPlace => ({
place_id: 4,
trip_id: 2,
trip_title: 'Venice',
name: 'Piazza San Marco',
lat: 45.434,
lng: 12.3388,
days: ['2026-05-14'],
time: null,
category_color: '#e11d48',
category_icon: null,
entry_id: null,
...over,
});
const stay = (over: Partial<JourneyMapStay> = {}): JourneyMapStay => ({
id: 9,
entry_id: 7,
name: 'Rialto',
lat: 45.438,
lng: 12.336,
started_at: '2026-05-14T09:00:00+02:00',
ended_at: '2026-05-14T10:00:00+02:00',
...over,
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
describe('journey map layers', () => {
it('FE-JOURNEY-MAPLAYERS-001: keeps the switches per journey, and a link only by a hash of its token', () => {
expect(layersStorageKey({ journeyId: 12 })).toBe('trek:journey-map-layers:12');
const pub = layersStorageKey({ token: 'secret-token-value' });
expect(pub).toMatch(/^trek:journey-map-layers:pub:[0-9a-z]+$/);
expect(pub).not.toContain('secret');
expect(layersStorageKey({ token: 'secret-token-value' })).toBe(pub);
expect(layersStorageKey({ token: 'other' })).not.toBe(pub);
});
it('FE-JOURNEY-MAPLAYERS-002: reads a stored value leniently, a layer it does not name at its default', () => {
expect(parseStoredLayers(null)).toEqual(ALL_VISIBLE);
expect(parseStoredLayers('not json')).toEqual(ALL_VISIBLE);
expect(parseStoredLayers('[true]')).toEqual(ALL_VISIBLE);
expect(parseStoredLayers('{"plan":false,"photos":"no","extra":true}')).toEqual({ ...ALL_VISIBLE, plan: false });
});
it('FE-JOURNEY-MAPLAYERS-003: storage that refuses leaves every layer on and the switch working for the visit', () => {
writeStoredLayers('k', { ...ALL_VISIBLE, entries: false });
expect(readStoredLayers('k')).toEqual({ ...ALL_VISIBLE, entries: false });
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked');
});
expect(readStoredLayers('k')).toEqual(ALL_VISIBLE);
expect(() => writeStoredLayers('k', ALL_VISIBLE)).not.toThrow();
});
it('FE-JOURNEY-MAPLAYERS-004: a planned place an entry pin came from is that pin, with the check; without entries it stands alone', () => {
const written = place({ place_id: 4, entry_id: 7 });
const alone = place({ place_id: 5, entry_id: null, category_color: 'red;evil' });
const onMap = new Set(['7']);
const both = buildMarks({
places: [written, alone],
stays: [],
visibility: ALL_VISIBLE,
entryIdsOnMap: onMap,
stayColor: () => null,
});
expect(both.marks.map((m) => m.key)).toEqual(['plan:5']);
expect(both.marks[0].color).toBe('#64748b');
expect([...both.plannedEntryIds]).toEqual(['7']);
const noEntries = buildMarks({
places: [written, alone],
stays: [stay()],
visibility: { ...ALL_VISIBLE, entries: false },
entryIdsOnMap: onMap,
stayColor: () => '#14b8a6',
});
expect(noEntries.marks.map((m) => m.key)).toEqual(['plan:4', 'plan:5']);
expect(noEntries.plannedEntryIds.size).toBe(0);
});
it('FE-JOURNEY-MAPLAYERS-005: stays follow the entries switch in the colour of their day, the plan its own switch', () => {
const marks = buildMarks({
places: [place()],
stays: [stay(), stay({ id: 10, entry_id: 8 })],
visibility: { ...ALL_VISIBLE, plan: false },
entryIdsOnMap: new Set(),
stayColor: (entryId) => (entryId === 7 ? '#14b8a6' : null),
}).marks;
expect(marks.map((m) => [m.key, m.color])).toEqual([
['stay:9', '#14b8a6'],
['stay:10', '#64748b'],
]);
});
it('FE-JOURNEY-MAPLAYERS-006: a failed read makes every source unavailable; nothing read yet is idle, not empty', () => {
expect(sourcesOf(null).places).toEqual({ status: 'unavailable' });
expect(sourcesOf(undefined)).toEqual({});
expect(stateOf(undefined, false, 0)).toBe('idle');
expect(stateOf(undefined, true, 0)).toBe('loading');
expect(stateOf({ status: 'ok', items: [] }, false, 0)).toBe('empty');
expect(stateOf({ status: 'ok', items: [1] }, false, 1)).toBe('ready');
});
it('FE-JOURNEY-MAPLAYERS-007: a link offers no switch for what it withholds; a journey always offers its recordings', () => {
const withheld = { status: 'withheld' } as const;
const shared = buildControls({
ownJourney: false,
sources: { places: withheld, routes: withheld, stays: { status: 'ok', items: [] }, recordings: withheld },
loading: false,
counts: { places: 0, routes: 0, recordedDays: 0, entries: 3, stays: 0, photos: 0 },
});
expect(shared.map((c) => [c.layer, c.state])).toEqual([
['entries', 'ready'],
['photos', 'empty'],
]);
const own = buildControls({
ownJourney: true,
sources: {},
loading: false,
counts: { places: 0, routes: 0, recordedDays: 2, entries: 0, stays: 0, photos: 4 },
});
expect(own.map((c) => [c.layer, c.state, c.count])).toEqual([
['plan', 'idle', 0],
['recorded', 'ready', 2],
['entries', 'empty', 0],
['photos', 'ready', 4],
]);
});
it('FE-JOURNEY-MAPLAYERS-008: a failed route or stay read warns on its switch while the rest still counts', () => {
const controls = buildControls({
ownJourney: true,
sources: {
places: { status: 'ok', items: [] },
routes: { status: 'unavailable' },
stays: { status: 'unavailable' },
},
loading: false,
counts: { places: 3, routes: 0, recordedDays: 0, entries: 5, stays: 0, photos: 0 },
});
expect(controls.find((c) => c.layer === 'plan')).toEqual({ layer: 'plan', count: 3, state: 'unavailable' });
expect(controls.find((c) => c.layer === 'entries')).toEqual({ layer: 'entries', count: 5, state: 'unavailable' });
});
it('FE-JOURNEY-MAPLAYERS-009: a journey whose recordings could not be read says so instead of nothing recorded', () => {
const recorded = (recordedRead: 'loading' | 'ready' | 'unavailable', recordedDays: number) =>
buildControls({
ownJourney: true,
sources: {},
loading: false,
recordedRead,
counts: { places: 0, routes: 0, recordedDays, entries: 0, stays: 0, photos: 0 },
}).find((c) => c.layer === 'recorded')?.state;
expect(recorded('unavailable', 0)).toBe('unavailable');
expect(recorded('unavailable', 2)).toBe('unavailable');
expect(recorded('loading', 0)).toBe('loading');
expect(recorded('loading', 1)).toBe('ready');
expect(recorded('ready', 0)).toBe('empty');
});
});
@@ -0,0 +1,251 @@
import type {
JourneyMapLayerResult,
JourneyMapLayersResponse,
JourneyMapPlace,
JourneyMapStay,
JourneyPublicMapLayersResponse,
JourneyTrack,
} from '@trek/shared';
import { safeColor, type JourneyMapMark } from './journeyMapMarks';
/**
* The four layers of a journey map (JRN-04), switched on and off one by one
* by each reader for themselves. A switch only ever hides: it changes no
* trip, entry or photo, and it never shows a reader what the journey does not
* give them.
*/
const JOURNEY_MAP_LAYERS = ['plan', 'recorded', 'entries', 'photos'] as const;
export type JourneyMapLayer = (typeof JOURNEY_MAP_LAYERS)[number];
export type JourneyMapVisibility = Record<JourneyMapLayer, boolean>;
/** The layers a renderer draws itself and switches in place; the photos it is handed or not. */
export type JourneyMapLayerVisibility = Pick<JourneyMapVisibility, 'plan' | 'recorded' | 'entries'>;
export const ALL_VISIBLE: JourneyMapVisibility = { plan: true, recorded: true, entries: true, photos: true };
/** What a layer's switch tells the reader beside its count; `idle` is a layer not read yet because it is off. */
type JourneyMapLayerState = 'ready' | 'loading' | 'unavailable' | 'empty' | 'idle';
export interface JourneyMapLayerControl {
layer: JourneyMapLayer;
count: number;
state: JourneyMapLayerState;
}
/** A switch's name, and what its tooltip adds when the layer has nothing to draw or could not be read. */
export const LAYER_TEXT: Record<JourneyMapLayer, { labelKey: string; emptyKey: string; unavailableKey: string }> = {
plan: {
labelKey: 'journey.map.layer.plan',
emptyKey: 'journey.map.layer.planEmpty',
unavailableKey: 'journey.map.layer.planUnavailable',
},
recorded: {
labelKey: 'journey.map.layer.recorded',
emptyKey: 'journey.map.layer.recordedEmpty',
unavailableKey: 'journey.map.layer.recordedUnavailable',
},
entries: {
labelKey: 'journey.map.layer.entries',
emptyKey: 'journey.map.layer.entriesEmpty',
unavailableKey: 'journey.map.layer.entriesUnavailable',
},
photos: {
labelKey: 'journey.map.layer.photos',
emptyKey: 'journey.map.layer.photosEmpty',
unavailableKey: 'journey.map.layer.photosEmpty',
},
};
const STORAGE_PREFIX = 'trek:journey-map-layers:';
/**
* Where a reader's switches are kept: per journey, and per link on a shared
* page. The link's token is the credential to the journey, so only a hash of
* it is written to the browser's storage.
*/
export function layersStorageKey(scope: { journeyId: number } | { token: string }): string {
if ('journeyId' in scope) return `${STORAGE_PREFIX}${scope.journeyId}`;
let hash = 0x811c9dc5;
for (let i = 0; i < scope.token.length; i++) {
hash ^= scope.token.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return `${STORAGE_PREFIX}pub:${(hash >>> 0).toString(36)}`;
}
/** A stored value read leniently: a layer it does not name stays at its default. */
export function parseStoredLayers(raw: string | null): JourneyMapVisibility {
if (!raw) return ALL_VISIBLE;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return ALL_VISIBLE;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return ALL_VISIBLE;
const stored = parsed as Record<string, unknown>;
const next = { ...ALL_VISIBLE };
for (const layer of JOURNEY_MAP_LAYERS) {
if (typeof stored[layer] === 'boolean') next[layer] = stored[layer] as boolean;
}
return next;
}
export function readStoredLayers(key: string): JourneyMapVisibility {
try {
return parseStoredLayers(window.localStorage.getItem(key));
} catch {
return ALL_VISIBLE;
}
}
export function writeStoredLayers(key: string, visibility: JourneyMapVisibility): void {
try {
window.localStorage.setItem(key, JSON.stringify(visibility));
} catch {
// A private window or blocked storage: the switch works for this visit only.
}
}
/** The items of a source that answered, nothing for one that did not. */
export function itemsOf<T>(layer: JourneyMapLayerResult<T> | undefined): T[] {
return layer?.status === 'ok' ? layer.items : [];
}
/** How a source that the reader may see stands, as its switch shows it. */
export function stateOf(
layer: JourneyMapLayerResult<unknown> | undefined,
loading: boolean,
count: number
): JourneyMapLayerState {
if (layer?.status === 'unavailable') return 'unavailable';
if (!layer) return loading ? 'loading' : 'idle';
return count > 0 ? 'ready' : 'empty';
}
/**
* The marks of the plan and of the stays, as the renderers draw them. A
* planned place an entry on the map came from is left to that entry's pin,
* which carries the plan's check instead: one spot, one pin. Without the
* entries layer the plan pin stands on its own again.
*/
export function buildMarks({
places,
stays,
visibility,
entryIdsOnMap,
stayColor,
}: {
places: JourneyMapPlace[];
stays: JourneyMapStay[];
visibility: JourneyMapVisibility;
entryIdsOnMap: ReadonlySet<string>;
stayColor: (entryId: number) => string | null;
}): { marks: JourneyMapMark[]; plannedEntryIds: ReadonlySet<string> } {
const marks: JourneyMapMark[] = [];
const plannedEntryIds = new Set<string>();
if (visibility.plan) {
for (const place of places) {
const entryId = place.entry_id === null ? null : String(place.entry_id);
if (visibility.entries && entryId && entryIdsOnMap.has(entryId)) {
plannedEntryIds.add(entryId);
continue;
}
marks.push({
kind: 'plan',
key: `plan:${place.place_id}`,
lat: place.lat,
lng: place.lng,
color: safeColor(place.category_color),
place,
});
}
}
if (visibility.entries) {
for (const stay of stays) {
marks.push({
kind: 'stay',
key: `stay:${stay.id}`,
lat: stay.lat,
lng: stay.lng,
color: safeColor(stayColor(stay.entry_id)),
stay,
});
}
}
return { marks, plannedEntryIds };
}
/** What the server answered for a map: a journey's own layers, or a link's with its recorded days. */
export type JourneyMapAnswer = JourneyMapLayersResponse | JourneyPublicMapLayersResponse;
const UNAVAILABLE = { status: 'unavailable' } as const;
/**
* Every source of an answer on its own: undefined while nothing was read, all
* unavailable when the read itself failed (null). The recorded days come only
* with a link's answer; a journey's own map reads them with their details.
*/
export function sourcesOf(answer: JourneyMapAnswer | null | undefined): {
places?: JourneyMapLayersResponse['places'];
routes?: JourneyMapLayersResponse['routes'];
stays?: JourneyMapLayersResponse['stays'];
recordings?: JourneyPublicMapLayersResponse['recordings'];
} {
if (answer === null) return { places: UNAVAILABLE, routes: UNAVAILABLE, stays: UNAVAILABLE, recordings: UNAVAILABLE };
if (!answer) return {};
const recordings = 'recordings' in answer ? (answer as JourneyPublicMapLayersResponse).recordings : undefined;
return { places: answer.places, routes: answer.routes, stays: answer.stays, recordings };
}
/**
* The switches a reader is offered, each with its count and state. A layer a
* share link withholds is not offered at all; one with nothing to draw is,
* dimmed, with the reason in its tooltip. A journey's own map always offers
* what was recorded, which the screen reads itself.
*/
export function buildControls({
ownJourney,
sources,
loading,
counts,
recordedRead = 'ready',
}: {
ownJourney: boolean;
sources: ReturnType<typeof sourcesOf>;
loading: boolean;
/** How the screen's own read of what was recorded went, for a journey's own map. */
recordedRead?: 'loading' | 'ready' | 'unavailable';
counts: { places: number; routes: number; recordedDays: number; entries: number; stays: number; photos: number };
}): JourneyMapLayerControl[] {
const list: JourneyMapLayerControl[] = [];
if (sources.places?.status !== 'withheld') {
const state =
sources.routes?.status === 'unavailable'
? 'unavailable'
: stateOf(sources.places, loading, counts.places + counts.routes);
list.push({ layer: 'plan', count: counts.places, state });
}
if (ownJourney || sources.recordings?.status !== 'withheld') {
const state = ownJourney
? ownRecordedState(recordedRead, counts.recordedDays)
: stateOf(sources.recordings, loading, counts.recordedDays);
list.push({ layer: 'recorded', count: counts.recordedDays, state });
}
const entriesState =
sources.stays?.status === 'unavailable' ? 'unavailable' : counts.entries + counts.stays > 0 ? 'ready' : 'empty';
list.push({ layer: 'entries', count: counts.entries, state: entriesState });
list.push({ layer: 'photos', count: counts.photos, state: counts.photos > 0 ? 'ready' : 'empty' });
return list;
}
function ownRecordedState(read: 'loading' | 'ready' | 'unavailable', days: number): JourneyMapLayerState {
if (read === 'unavailable') return 'unavailable';
if (days > 0) return 'ready';
return read === 'loading' ? 'loading' : 'empty';
}
/** Whether a track belongs to the plan (a trip's GPX) or to what was recorded. */
export function isPlannedTrack(track: JourneyTrack): boolean {
return !track.kind || track.kind === 'trip_gpx';
}
@@ -0,0 +1,57 @@
// FE-JOURNEY-MAPMARKS-001 to FE-JOURNEY-MAPMARKS-003
//
// JRN-04: the plan and stay marks both renderers draw, and how they join.
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
MARK_CLUSTER_PX,
clusterPoints,
markHitSvg,
planPinSvg,
safeColor,
type JourneyMapMark,
} from './journeyMapMarks';
const planMark = (key: string, lat: number): JourneyMapMark => ({
kind: 'plan',
key,
lat,
lng: 12,
color: '#e11d48',
place: { name: key } as JourneyMapPlace,
});
const stayMark = (key: string, lat: number): JourneyMapMark => ({
kind: 'stay',
key,
lat,
lng: 12,
color: '#14b8a6',
stay: { name: key } as JourneyMapStay,
});
describe('journey map marks', () => {
it('FE-JOURNEY-MAPMARKS-001: joins only what would overlap on screen, anchored on the first', () => {
const project = (lat: number, lng: number) => ({ x: lng, y: lat * 100 });
const hits = clusterPoints([planMark('a', 1), stayMark('b', 1.05), planMark('c', 5)], project, MARK_CLUSTER_PX);
expect(hits.map((h) => h.members.map((m) => m.key))).toEqual([['a', 'b'], ['c']]);
expect(hits[0]).toMatchObject({ lat: 1, lng: 12 });
});
it('FE-JOURNEY-MAPMARKS-002: a lone plan is a hollow dashed pin, a lone stay a dot, several a disc with the count', () => {
const plan = markHitSvg([planMark('a', 1)]);
expect(plan).toMatchObject({ anchor: 'bottom', width: 22, height: 28 });
expect(plan.html).toContain('stroke-dasharray');
expect(markHitSvg([stayMark('b', 1)])).toMatchObject({ anchor: 'center', width: 14 });
const several = markHitSvg([planMark('a', 1), stayMark('b', 1), planMark('c', 1)]);
expect(several.anchor).toBe('center');
expect(several.html).toContain('>3<');
});
it('FE-JOURNEY-MAPMARKS-003: only a hex colour reaches the markup', () => {
expect(safeColor('#ABCDEF')).toBe('#ABCDEF');
expect(safeColor('#abc')).toBe('#abc');
expect(safeColor('"/><script>')).toBe('#64748b');
expect(safeColor(null)).toBe('#64748b');
expect(planPinSvg('red" onload="x')).not.toContain('onload');
});
});
@@ -0,0 +1,120 @@
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
/**
* The marks a journey map draws beside its entries and photos (JRN-04): the
* places the plan puts on a day, and the stays a journal entry was taken over
* from. One module for both renderers, so a mark says the same on Leaflet and
* on GL. The markup is SVG with its colours as attributes: it is drawn over
* any basemap, so it keeps its own contrast instead of following the scheme.
*/
export type JourneyMapMark =
| { kind: 'plan'; key: string; lat: number; lng: number; color: string; place: JourneyMapPlace }
| { kind: 'stay'; key: string; lat: number; lng: number; color: string; stay: JourneyMapStay };
/** The colour of a planned place whose category has none, a neutral slate that reads on light and dark tiles. */
export const PLAN_FALLBACK_COLOR = '#64748b';
/**
* Marks closer than this on screen are one hit: the reader could not pick one
* of them anyway. Tighter than the photo grid, since a mark is a pin and not a
* thumbnail, so marks a street apart stay apart.
*/
export const MARK_CLUSTER_PX = 26;
const PLAN_PIN_W = 22;
const PLAN_PIN_H = 28;
const STAY_DOT = 14;
const MARK_CLUSTER_SIZE = 28;
/**
* Grid clustering in screen space, shared by both renderers: points whose
* projected position falls in the same `px` cell become one, anchored on the
* first so the pin points where that member is. `project` is the engine's own
* lat/lng to container-pixel conversion.
*/
export function clusterPoints<T extends { lat: number; lng: number }>(
points: readonly T[],
project: (lat: number, lng: number) => { x: number; y: number },
px: number
): { lat: number; lng: number; members: T[] }[] {
const buckets = new Map<string, T[]>();
for (const point of points) {
const pt = project(point.lat, point.lng);
const key = `${Math.round(pt.x / px)}:${Math.round(pt.y / px)}`;
const list = buckets.get(key);
if (list) list.push(point);
else buckets.set(key, [point]);
}
return [...buckets.values()].map((members) => ({ lat: members[0].lat, lng: members[0].lng, members }));
}
/** Only a `#rgb`/`#rrggbb` colour reaches markup; anything else a category holds falls back. */
export function safeColor(color: string | null | undefined, fallback = PLAN_FALLBACK_COLOR): string {
return color && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color) ? color : fallback;
}
/**
* A planned place: a hollow pin, dashed in its category colour, smaller than
* an entry pin. Hollow on purpose: the plan is what was meant, not what was
* lived, and it never reads as a filled journal pin.
*/
export function planPinSvg(color: string): string {
const c = safeColor(color);
return `<svg width="${PLAN_PIN_W}" height="${PLAN_PIN_H}" viewBox="0 0 22 28" fill="none" xmlns="http://www.w3.org/2000/svg" style="display:block;filter:drop-shadow(0 1px 2px rgba(0,0,0,.25))">
<path d="M11 26.5C11 26.5 20.5 17.4 20.5 10.2C20.5 5 16.25 1.5 11 1.5C5.75 1.5 1.5 5 1.5 10.2C1.5 17.4 11 26.5 11 26.5Z" fill="#ffffff" fill-opacity="0.92" stroke="${c}" stroke-width="2" stroke-dasharray="3 2"/>
<circle cx="11" cy="10.5" r="3" fill="${c}"/>
</svg>`;
}
/** A stay of a day report: a dot in the colour of its day, ringed so it holds on any tile. */
function stayDotSvg(color: string): string {
const c = safeColor(color);
return `<svg width="${STAY_DOT}" height="${STAY_DOT}" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg" style="display:block;filter:drop-shadow(0 1px 2px rgba(0,0,0,.3))">
<circle cx="7" cy="7" r="5.5" fill="${c}" stroke="#ffffff" stroke-width="2"/>
</svg>`;
}
/**
* Several marks on one spot: a stacked disc with the count, ringed in the
* colours of up to two members so the reader sees what kinds lie beneath.
*/
function markClusterSvg(count: number, colors: string[]): string {
const [a, b] = [safeColor(colors[0]), safeColor(colors[1] ?? colors[0])];
const label = count > 99 ? '99+' : String(count);
return `<svg width="${MARK_CLUSTER_SIZE}" height="${MARK_CLUSTER_SIZE}" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" style="display:block;cursor:pointer;filter:drop-shadow(0 1px 3px rgba(0,0,0,.3))">
<circle cx="16" cy="12" r="10" fill="#ffffff" stroke="${b}" stroke-width="2"/>
<circle cx="12" cy="16" r="10" fill="#ffffff" stroke="${a}" stroke-width="2.5"/>
<text x="12" y="16" text-anchor="middle" dominant-baseline="central" fill="#111827" font-family="'Poppins',system-ui,sans-serif" font-size="10" font-weight="700">${label}</text>
</svg>`;
}
/** The markup of one hit on the map: a mark alone, or the disc of several. */
export function markHitSvg(members: readonly JourneyMapMark[]): {
html: string;
width: number;
height: number;
anchor: 'bottom' | 'center';
} {
if (members.length > 1) {
return {
html: markClusterSvg(
members.length,
members.map((m) => m.color)
),
width: MARK_CLUSTER_SIZE,
height: MARK_CLUSTER_SIZE,
anchor: 'center',
};
}
const mark = members[0];
return mark.kind === 'plan'
? { html: planPinSvg(mark.color), width: PLAN_PIN_W, height: PLAN_PIN_H, anchor: 'bottom' }
: { html: stayDotSvg(mark.color), width: STAY_DOT, height: STAY_DOT, anchor: 'center' };
}
/**
* The small check an entry pin carries when the plan had that place too: plan
* and memory at one spot read as one pin, not two on top of each other. Sits
* at the pin's top corner, inside its 28 x 36 box.
*/
export const PLANNED_BADGE_SVG = `<circle cx="22.5" cy="5.5" r="5" fill="#ffffff" stroke="#16a34a" stroke-width="1.2"/><path d="M20.4 5.6l1.5 1.5 2.8-2.9" fill="none" stroke="#16a34a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`;
@@ -1,3 +1,5 @@
import { clusterPoints } from './journeyMapMarks'
/** A photo that knows where it was taken (#1614). */ /** A photo that knows where it was taken (#1614). */
export interface MapPhoto { export interface MapPhoto {
id: string id: string
@@ -22,21 +24,9 @@ export function clusterPhotos(
photos: MapPhoto[], photos: MapPhoto[],
project: (lat: number, lng: number) => { x: number; y: number }, project: (lat: number, lng: number) => { x: number; y: number },
): { lat: number; lng: number; members: MapPhoto[] }[] { ): { lat: number; lng: number; members: MapPhoto[] }[] {
const buckets = new Map<string, MapPhoto[]>() // Anchored on the first member rather than the centroid: the thumbnail shown
for (const photo of photos) { // is that photo's, so the pin should point where that picture was taken.
const pt = project(photo.lat, photo.lng) return clusterPoints(photos, project, PHOTO_CLUSTER_PX)
const key = `${Math.round(pt.x / PHOTO_CLUSTER_PX)}:${Math.round(pt.y / PHOTO_CLUSTER_PX)}`
const list = buckets.get(key)
if (list) list.push(photo)
else buckets.set(key, [photo])
}
return [...buckets.values()].map(members => ({
// Anchor on the first member rather than the centroid: the thumbnail shown is
// that photo's, so the pin should point where that picture was taken.
lat: members[0].lat,
lng: members[0].lng,
members,
}))
} }
export function photoMarkerHtml(thumbUrl: string, count: number): string { export function photoMarkerHtml(thumbUrl: string, count: number): string {
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { OnlineOnlyError } from '../../repo/onlineOnly';
import {
deviceOffsetOn,
formatOffset,
modeLabel,
recordingErrorCode,
recordingErrorText,
sketchPaths,
zoneOptions,
} from './journeyRecordingModel';
// FE-JRN-RECORDING-MODEL-001 to FE-JRN-RECORDING-MODEL-006
const t = (key: string) => `t:${key}`;
const httpError = (code: string) => ({ response: { data: { error: 'English', code } } });
describe('journeyRecordingModel', () => {
it('FE-JRN-RECORDING-MODEL-001: spells an offset the way a zone is named', () => {
expect(formatOffset(120)).toBe('UTC+02:00');
expect(formatOffset(-210)).toBe('UTC-03:30');
expect(formatOffset(0)).toBe('UTC+00:00');
expect(formatOffset(345)).toBe('UTC+05:45');
expect(deviceOffsetOn('2026-05-14')).toBe(-new Date('2026-05-14T12:00:00').getTimezoneOffset());
});
it('FE-JRN-RECORDING-MODEL-002: lists every zone once, marking the one of Dawarich and the one of this device', () => {
const options = zoneOptions(t, 120, 60);
expect(options[0]).toEqual({ value: -720, label: 'UTC-12:00' });
expect(options[options.length - 1]).toEqual({ value: 840, label: 'UTC+14:00' });
expect(options.find((o) => o.value === 120)).toEqual({
value: 120,
label: 'UTC+02:00',
badge: 't:journey.recording.zoneSource',
});
expect(options.find((o) => o.value === 60)).toEqual({
value: 60,
label: 'UTC+01:00',
badge: 't:journey.recording.zoneDevice',
});
expect(new Set(options.map((o) => o.value)).size).toBe(options.length);
// An odd zone a device really is in still gets its row.
expect(zoneOptions(t, null, 17).some((o) => o.value === 17)).toBe(true);
});
it('FE-JRN-RECORDING-MODEL-003: names the modes TREK knows and shows any other as Dawarich spells it', () => {
expect(modeLabel('walking', t)).toBe('t:journey.recording.mode.walking');
expect(modeLabel('hovercraft', t)).toBe('hovercraft');
});
it('FE-JRN-RECORDING-MODEL-004: says a refusal in the reader’s language, from the code the server sent', () => {
expect(recordingErrorText(new OnlineOnlyError(), t)).toBe('t:dawarich.error.offline');
expect(recordingErrorText(httpError('recording_exists'), t)).toBe('t:journey.recording.error.recording_exists');
expect(recordingErrorCode(httpError('recording_changed'))).toBe('recording_changed');
const echo = (key: string) => (key === 'dawarich.error.not_connected' ? 'Not connected' : key);
expect(recordingErrorText(httpError('not_connected'), echo)).toBe('Not connected');
expect(recordingErrorText(httpError('brand_new'), echo)).toBe('dawarich.error.unknown');
expect(recordingErrorText(new Error('boom'), echo)).toBe('dawarich.error.unknown');
expect(recordingErrorCode(new Error('boom'))).toBeNull();
});
it('FE-JRN-RECORDING-MODEL-005: draws each piece on its own, north up, inside the box', () => {
const { paths, ends } = sketchPaths(
[
{
points: [
[45, 12],
[45.01, 12.01],
],
},
{
points: [
[45.05, 12.05],
[45.06, 12.07],
],
},
],
320,
150
);
expect(paths).toHaveLength(2);
expect(paths.every((d) => d.startsWith('M') && !d.includes('M', 1))).toBe(true);
expect(ends).toHaveLength(4);
for (const [x, y] of ends) {
expect(x).toBeGreaterThanOrEqual(0);
expect(x).toBeLessThanOrEqual(320);
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(150);
}
// The northernmost point sits higher in the box than the southernmost.
expect(ends[3]?.[1]).toBeLessThan(ends[0]?.[1] ?? 0);
});
it('FE-JRN-RECORDING-MODEL-006: nothing recorded is nothing drawn, and a lone fix is no line', () => {
expect(sketchPaths([], 320, 150)).toEqual({ paths: [], ends: [] });
expect(sketchPaths([{ points: [[45, 12]] }], 320, 150)).toEqual({ paths: [], ends: [] });
});
});
@@ -0,0 +1,159 @@
import { OnlineOnlyError } from '../../repo/onlineOnly';
type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* The zones a day can be cut in: every whole hour from UTC-12 to UTC+14 and
* the half and three-quarter hours places actually keep (India, Nepal,
* Newfoundland, Iran, parts of Australia, the Chathams).
*/
const ZONE_OFFSETS = [
...Array.from({ length: 27 }, (_, i) => (i - 12) * 60),
-570,
-210,
210,
270,
330,
345,
390,
525,
570,
630,
765,
].sort((a, b) => a - b);
/** `UTC+05:30`, `UTC-03:00`, `UTC+00:00`. */
export function formatOffset(minutes: number): string {
const sign = minutes < 0 ? '-' : '+';
const abs = Math.abs(minutes);
const hours = String(Math.floor(abs / 60)).padStart(2, '0');
const rest = String(abs % 60).padStart(2, '0');
return `UTC${sign}${hours}:${rest}`;
}
/** The zone this device is in on that day, so a day abroad in summer gets its summer offset. */
export function deviceOffsetOn(date: string): number {
return -new Date(`${date}T12:00:00`).getTimezoneOffset();
}
interface ZoneOption {
value: number;
label: string;
badge?: string;
}
/**
* The zone picker's list, Dawarich's own zone and this device's marked, both
* kept in the list even when they are no whole or usual offset.
*/
export function zoneOptions(t: Translate, sourceOffset: number | null, deviceOffset: number): ZoneOption[] {
const offsets = new Set(ZONE_OFFSETS);
if (sourceOffset !== null) offsets.add(sourceOffset);
offsets.add(deviceOffset);
return [...offsets]
.sort((a, b) => a - b)
.map((minutes) => {
const badge =
minutes === sourceOffset
? t('journey.recording.zoneSource')
: minutes === deviceOffset
? t('journey.recording.zoneDevice')
: undefined;
return badge
? { value: minutes, label: formatOffset(minutes), badge }
: { value: minutes, label: formatOffset(minutes) };
});
}
/** The modes Dawarich gives a track, as far as TREK names them; anything else is shown as Dawarich spells it. */
const KNOWN_MODES = new Set([
'walking',
'running',
'cycling',
'driving',
'motorcycle',
'bus',
'train',
'flying',
'boat',
'stationary',
'unknown',
]);
export function modeLabel(mode: string, t: Translate): string {
return KNOWN_MODES.has(mode) ? t(`journey.recording.mode.${mode}`) : mode;
}
/**
* A failed preview, save or removal in the reader's language. The server
* names its reason as a code: Dawarich's own reasons have their strings
* already, the journey's three have theirs.
*/
export function recordingErrorText(err: unknown, t: Translate): string {
if (err instanceof OnlineOnlyError) return t('dawarich.error.offline');
const code = recordingErrorCode(err);
if (code === 'recording_exists' || code === 'recording_changed' || code === 'recording_empty') {
return t(`journey.recording.error.${code}`);
}
return reasonText(code, t);
}
/** A reason Dawarich gave as a code, in the reader's language; one TREK has no words for reads as unknown. */
export function reasonText(code: string | null, t: Translate): string {
if (code) {
const key = `dawarich.error.${code}`;
const text = t(key);
if (text !== key) return text;
}
return t('dawarich.error.unknown');
}
export function recordingErrorCode(err: unknown): string | null {
const code = (err as { response?: { data?: { code?: unknown } } })?.response?.data?.code;
return typeof code === 'string' ? code : null;
}
/**
* The pieces of a day drawn into a box, north up, longitude shortened by the
* latitude so a walk keeps its shape. Answers one SVG path per piece, never a
* line between two of them.
*/
export function sketchPaths(
segments: ReadonlyArray<{ points: ReadonlyArray<readonly unknown[]> }>,
width: number,
height: number,
pad = 8
): { paths: string[]; ends: Array<[number, number]> } {
const all = segments.flatMap((s) => s.points);
if (all.length === 0) return { paths: [], ends: [] };
const lats = all.map((p) => Number(p[0]));
const lngs = all.map((p) => Number(p[1]));
const minLat = Math.min(...lats);
const maxLat = Math.max(...lats);
const minLng = Math.min(...lngs);
const maxLng = Math.max(...lngs);
const shrink = Math.cos((((minLat + maxLat) / 2) * Math.PI) / 180) || 1;
const spanX = Math.max((maxLng - minLng) * shrink, 1e-9);
const spanY = Math.max(maxLat - minLat, 1e-9);
const scale = Math.min((width - 2 * pad) / spanX, (height - 2 * pad) / spanY);
const offX = (width - spanX * scale) / 2;
const offY = (height - spanY * scale) / 2;
const at = (point: readonly unknown[]): [number, number] => [
Math.round((offX + (Number(point[1]) - minLng) * shrink * scale) * 10) / 10,
Math.round((offY + (maxLat - Number(point[0])) * scale) * 10) / 10,
];
const paths = segments
.filter((s) => s.points.length > 1)
.map((s) =>
s.points
.map(at)
.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x} ${y}`)
.join(' ')
);
const ends = segments.flatMap((s) => {
const first = s.points[0];
const last = s.points[s.points.length - 1];
return s.points.length > 1 && first && last ? [at(first), at(last)] : [];
});
return { paths, ends };
}
@@ -0,0 +1,159 @@
// FE-JRN-PREP-MODEL-001 to FE-JRN-PREP-MODEL-009
//
// JRN-03: where preparing a journey day stands, and the line under the switch.
import type { JourneyRecordingPrep } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
dayPrepNotice,
dayPrepOf,
dayPrepText,
dayPrepUnsettled,
prepReachOf,
prepStatusLine,
} from './journeyRecordingPrepModel';
/** Keys come back as they are, with their params; a Dawarich reason reads as its code, as reasonText needs a hit. */
const t = (key: string, params?: Record<string, string | number>) => {
if (key.startsWith('dawarich.error.')) return key.slice('dawarich.error.'.length).toUpperCase();
return params ? `${key} ${JSON.stringify(params)}` : key;
};
const NOW = new Date(2026, 4, 16, 12, 0);
const PERIOD = new Set(['2026-05-14', '2026-05-15', '2026-05-16', '2026-05-17']);
const REACHED = { prepared: true, outOfReach: false };
function prep(over: Partial<JourneyRecordingPrep> = {}): JourneyRecordingPrep {
return {
enabled: true,
connection: 'ready',
lastAttemptAt: new Date(2026, 4, 16, 10, 0).toISOString(),
lastSuccessAt: new Date(2026, 4, 16, 10, 0).toISOString(),
lastError: null,
...over,
};
}
describe('dayPrepOf', () => {
it('FE-JRN-PREP-MODEL-001: today runs, a day that ended before the last fetch is complete, one fetched too early is provisional', () => {
expect(dayPrepOf('2026-05-16', prep(), PERIOD, NOW)?.state).toBe('running');
expect(dayPrepOf('2026-05-14', prep(), PERIOD, NOW)?.state).toBe('complete');
const early = prep({ lastSuccessAt: new Date(2026, 4, 15, 18, 0).toISOString() });
expect(dayPrepOf('2026-05-15', early, PERIOD, NOW)).toEqual({
state: 'provisional',
failed: false,
lastSuccessAt: early.lastSuccessAt,
});
});
it('FE-JRN-PREP-MODEL-002: nothing fetched yet is pending, and a failed run marks the day as stale', () => {
expect(dayPrepOf('2026-05-14', prep({ lastSuccessAt: null }), PERIOD, NOW)?.state).toBe('pending');
expect(dayPrepOf('2026-05-14', prep({ lastError: 'unreachable' }), PERIOD, NOW)).toMatchObject({
state: 'complete',
failed: true,
});
});
it('FE-JRN-PREP-MODEL-003: no mark while off, without a connection, outside the trips or for a day still ahead', () => {
expect(dayPrepOf('2026-05-14', prep({ enabled: false }), PERIOD, NOW)).toBeNull();
expect(dayPrepOf('2026-05-14', prep({ connection: 'not_connected' }), PERIOD, NOW)).toBeNull();
expect(dayPrepOf('2026-05-20', prep(), PERIOD, NOW)).toBeNull();
expect(dayPrepOf('2026-05-17', prep(), PERIOD, NOW)).toBeNull();
expect(dayPrepOf('2026-05-14', null, PERIOD, NOW)).toBeNull();
});
});
describe('dayPrepText and dayPrepUnsettled', () => {
it('FE-JRN-PREP-MODEL-004: says why a day is not final, and only such a day is unsettled', () => {
const now = NOW.getTime();
expect(dayPrepText({ state: 'running', failed: true, lastSuccessAt: null }, t, 'en', now)).toBe(
'journey.prep.dayRunning journey.prep.dayFailed'
);
expect(dayPrepText({ state: 'pending', failed: false, lastSuccessAt: null }, t, 'en', now)).toBe(
'journey.prep.dayPending'
);
const provisional = {
state: 'provisional' as const,
failed: false,
lastSuccessAt: new Date(now - 7_200_000).toISOString(),
};
expect(dayPrepText(provisional, t, 'en', now)).toBe('journey.prep.dayProvisional {"time":"2 hours ago"}');
expect(dayPrepUnsettled(provisional)).toBe(true);
expect(dayPrepUnsettled({ state: 'complete', failed: false, lastSuccessAt: null })).toBe(false);
expect(dayPrepUnsettled({ state: 'complete', failed: true, lastSuccessAt: null })).toBe(true);
expect(dayPrepUnsettled(null)).toBe(false);
});
});
describe('prepStatusLine', () => {
const now = NOW.getTime();
it('FE-JRN-PREP-MODEL-005: says when the last fetch came through, or that none did yet', () => {
expect(prepStatusLine(prep(), REACHED, t, 'en', now)).toEqual({
text: 'journey.prep.lastSuccess {"time":"2 hours ago"}',
tone: 'muted',
});
expect(prepStatusLine(prep({ lastSuccessAt: null, lastAttemptAt: null }), REACHED, t, 'en', now)).toEqual({
text: 'journey.prep.never',
tone: 'muted',
});
expect(prepStatusLine(prep({ enabled: false }), REACHED, t, 'en', now)).toBeNull();
});
it('FE-JRN-PREP-MODEL-006: warns about what keeps it from working, a failure first and beside the state still shown', () => {
expect(prepStatusLine(prep({ connection: 'not_connected' }), REACHED, t, 'en', now)?.text).toBe(
'journey.prep.notConnected'
);
expect(prepStatusLine(prep(), { prepared: false, outOfReach: false }, t, 'en', now)?.text).toBe(
'journey.prep.noPeriods'
);
expect(prepStatusLine(prep({ connection: 'sync_off' }), REACHED, t, 'en', now)?.text).toBe('journey.prep.syncOff');
expect(prepStatusLine(prep({ lastError: 'unreachable' }), REACHED, t, 'en', now)).toEqual({
text: 'journey.prep.failed {"reason":"UNREACHABLE","time":"2 hours ago"}',
tone: 'warning',
});
expect(prepStatusLine(prep({ lastError: 'unauthorized', lastSuccessAt: null }), REACHED, t, 'en', now)).toEqual({
text: 'journey.prep.failedNever {"reason":"UNAUTHORIZED"}',
tone: 'warning',
});
});
});
describe('prepReachOf', () => {
it('FE-JRN-PREP-MODEL-007: only the periods the server reaches are prepared, an open trip up to today', () => {
const linked = new Set(['2024-01-10', '2024-01-11', ...PERIOD]);
const reach = prepReachOf(prep({ periods: [{ start: '2026-05-14', end: null }] }), linked, NOW);
expect([...reach.dates]).toEqual(['2026-05-14', '2026-05-15', '2026-05-16']);
expect(reach.outOfReach).toBe(true);
expect(dayPrepOf('2024-01-10', prep(), reach.dates, NOW)).toBeNull();
expect(prepReachOf(prep({ periods: [{ start: '2026-05-14', end: '2026-05-17' }] }), PERIOD, NOW)).toEqual({
dates: PERIOD,
outOfReach: false,
});
});
it('FE-JRN-PREP-MODEL-008: without periods in the answer the linked dates stand in, nothing out of reach', () => {
expect(prepReachOf(prep(), PERIOD, NOW)).toEqual({ dates: PERIOD, outOfReach: false });
expect(prepReachOf(null, PERIOD, NOW)).toEqual({ dates: PERIOD, outOfReach: false });
});
it('FE-JRN-PREP-MODEL-009: trips out of reach are named, alone when none is reached and after the state otherwise', () => {
const now = NOW.getTime();
expect(prepStatusLine(prep(), { prepared: false, outOfReach: true }, t, 'en', now)).toEqual({
text: 'journey.prep.outOfReach {"count":400}',
tone: 'warning',
});
expect(prepStatusLine(prep(), { prepared: true, outOfReach: true }, t, 'en', now)).toEqual({
text: 'journey.prep.lastSuccess {"time":"2 hours ago"} journey.prep.outOfReach {"count":400}',
tone: 'muted',
});
expect(dayPrepNotice({ state: 'running', failed: false, lastSuccessAt: null }, t, 'en', now)).toBe('');
expect(dayPrepNotice({ state: 'running', failed: true, lastSuccessAt: null }, t, 'en', now)).toBe(
'journey.prep.dayFailed'
);
expect(dayPrepNotice({ state: 'pending', failed: false, lastSuccessAt: null }, t, 'en', now)).toBe(
'journey.prep.dayPending'
);
});
});
@@ -0,0 +1,135 @@
import { JOURNEY_RECORDING_PREP_REACH_DAYS, type JourneyRecordingPrep } from '@trek/shared';
import { localIsoDate } from '../../utils/localDate';
import { relativeTime } from '../../utils/relativeTime';
import { reasonText } from './journeyRecordingModel';
type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* Where preparing one journey day stands (JRN-03), for its badge.
* `running`: the day is today. `provisional`: the last fetch that came through
* was before the day ended, so stays may be missing. `pending`: no fetch came
* through yet. `complete`: fetched after the day was over.
*/
export interface JourneyDayPrep {
state: 'running' | 'provisional' | 'pending' | 'complete';
/** The last run failed; what the day shows is as new as `lastSuccessAt`. */
failed: boolean;
lastSuccessAt: string | null;
}
/**
* Which days the caller's runs reach and whether some linked day is left out.
* The server names the trips its runs read; a trip still open runs up to
* today. Without that answer the linked dates stand in for them.
*/
export interface PrepReach {
dates: ReadonlySet<string>;
/** A linked day that is over is read by no run, its trip ended too long ago. */
outOfReach: boolean;
}
export function prepReachOf(
prep: JourneyRecordingPrep | null,
linkedDates: ReadonlySet<string>,
now: Date = new Date()
): PrepReach {
if (!prep?.periods) return { dates: linkedDates, outOfReach: false };
const today = localIsoDate(now);
const dates = new Set<string>();
for (const period of prep.periods) {
const end = new Date(`${period.end ?? today}T00:00:00`);
for (let d = new Date(`${period.start}T00:00:00`); d <= end; d.setDate(d.getDate() + 1)) {
dates.add(localIsoDate(d));
}
}
const outOfReach = [...linkedDates].some((date) => date < today && !dates.has(date));
return { dates, outOfReach };
}
/**
* A day the caller's runs reach while the journey prepares its days for a
* connected caller; null for every other day, including those still ahead.
*/
export function dayPrepOf(
date: string,
prep: JourneyRecordingPrep | null,
periodDates: ReadonlySet<string>,
now: Date = new Date()
): JourneyDayPrep | null {
if (!prep?.enabled || prep.connection === 'not_connected' || !periodDates.has(date)) return null;
const today = localIsoDate(now);
if (date > today) return null;
const base = { failed: prep.lastError !== null, lastSuccessAt: prep.lastSuccessAt };
if (date === today) return { state: 'running', ...base };
if (!prep.lastSuccessAt) return { state: 'pending', ...base };
const dayEnd = new Date(`${date}T00:00:00`);
dayEnd.setDate(dayEnd.getDate() + 1);
return { state: Date.parse(prep.lastSuccessAt) < dayEnd.getTime() ? 'provisional' : 'complete', ...base };
}
/** Whether a day's badge marks it as not final: still running, fetched too early, not fetched, or stale after a failure. */
export function dayPrepUnsettled(day: JourneyDayPrep | null | undefined): boolean {
return !!day && (day.state !== 'complete' || day.failed);
}
/** What a day's badge adds to its tooltip; empty for a day fetched whole by a run that came through. */
export function dayPrepText(day: JourneyDayPrep, t: Translate, locale: string, now: number = Date.now()): string {
const parts: string[] = [];
if (day.state === 'running') parts.push(t('journey.prep.dayRunning'));
if (day.state === 'pending') parts.push(t('journey.prep.dayPending'));
if (day.state === 'provisional' && day.lastSuccessAt) {
parts.push(t('journey.prep.dayProvisional', { time: relativeTime(Date.parse(day.lastSuccessAt), locale, now) }));
}
if (day.failed) parts.push(t('journey.prep.dayFailed'));
return parts.join(' ');
}
/** The same for the day review, which says on its own that the day is still running. */
export function dayPrepNotice(day: JourneyDayPrep, t: Translate, locale: string, now: number = Date.now()): string {
return day.state === 'running' ? (day.failed ? t('journey.prep.dayFailed') : '') : dayPrepText(day, t, locale, now);
}
/** The line under the switch in the journey settings, and whether it asks for attention. */
export interface PrepStatusLine {
text: string;
tone: 'muted' | 'warning';
}
/**
* What the caller's own preparation of a journey looks like right now, for
* the settings and the day review: why nothing happens (no connection, no
* dated trip, every trip out of reach, background sync off), the last failure
* next to the state still shown, or when it last came through, followed by a
* word on trips left out. Null while the switch is off.
*/
export function prepStatusLine(
prep: JourneyRecordingPrep,
reach: { prepared: boolean; outOfReach: boolean },
t: Translate,
locale: string,
now: number = Date.now()
): PrepStatusLine | null {
if (!prep.enabled) return null;
if (prep.connection === 'not_connected') return { text: t('journey.prep.notConnected'), tone: 'warning' };
const outOfReachText = t('journey.prep.outOfReach', { count: JOURNEY_RECORDING_PREP_REACH_DAYS });
if (!reach.prepared) {
return { text: reach.outOfReach ? outOfReachText : t('journey.prep.noPeriods'), tone: 'warning' };
}
const line = baseStatusLine(prep, t, locale, now);
return reach.outOfReach ? { ...line, text: `${line.text} ${outOfReachText}` } : line;
}
function baseStatusLine(prep: JourneyRecordingPrep, t: Translate, locale: string, now: number): PrepStatusLine {
const success = prep.lastSuccessAt ? relativeTime(Date.parse(prep.lastSuccessAt), locale, now) : null;
if (prep.lastError) {
const reason = reasonText(prep.lastError, t);
return {
text: success ? t('journey.prep.failed', { reason, time: success }) : t('journey.prep.failedNever', { reason }),
tone: 'warning',
};
}
if (prep.connection === 'sync_off') return { text: t('journey.prep.syncOff'), tone: 'warning' };
if (!success) return { text: t('journey.prep.never'), tone: 'muted' };
return { text: t('journey.prep.lastSuccess', { time: success }), tone: 'muted' };
}
@@ -0,0 +1,184 @@
// FE-JRN-DAYIMPORT-001 to FE-JRN-DAYIMPORT-008
//
// JRN-02: taking a recorded day with its stays into the journal, for the
// desktop dialog and the phone sheet alike. Nothing is written before the
// button; a failed take-over reads the day again and keeps what was chosen;
// the route goes along only when asked, and a route that fails after the
// drafts keeps the dialog open to try the route alone.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildDayStays, buildRecordedDay, buildRecordingPreview } from '../../../tests/helpers/journeyRecording';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { TranslationProvider } from '../../i18n/TranslationContext';
import { localIsoDate } from '../../utils/localDate';
import { useJourneyDayImport } from './useJourneyDayImport';
const repo = vi.hoisted(() => ({
reviewDay: vi.fn(),
takeOverDay: vi.fn(),
preview: vi.fn(),
save: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const HASH = 'a'.repeat(64);
const httpError = (code: string) => Object.assign(new Error(code), { response: { data: { error: code, code } } });
function setup(over: { storedRoute?: ReturnType<typeof buildRecordedDay>; date?: string } = {}) {
const onTakenOver = vi.fn();
const onKept = vi.fn();
const onClose = vi.fn();
const view = renderHook(
() =>
useJourneyDayImport({
journeyId: 7,
date: over.date ?? '2026-05-14',
dayColor: '#ff0055',
storedRoute: over.storedRoute,
onTakenOver,
onKept,
onClose,
}),
{ wrapper: TranslationProvider }
);
return { ...view, onTakenOver, onKept, onClose };
}
beforeEach(() => {
for (const fn of [...Object.values(repo), ...Object.values(toast)]) fn.mockReset();
repo.reviewDay.mockResolvedValue(buildDayStays());
repo.preview.mockResolvedValue(buildRecordingPreview());
repo.takeOverDay.mockResolvedValue({
entries: [{ id: 5, title: 'Piazza San Marco', stayCount: 2 }],
alreadyTaken: [],
skipped: [],
});
});
describe('useJourneyDayImport', () => {
it('FE-JRN-DAYIMPORT-001: reads the day, chooses its open stays, joins the same place twice and pins them in the day colour', async () => {
const { result } = setup();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(repo.reviewDay).toHaveBeenCalledWith(7, '2026-05-14', expect.any(AbortSignal));
expect(result.current.groups.map((g) => g.kind)).toEqual(['single', 'merged', 'single']);
expect(result.current.problem).toBeNull();
expect(result.current.mapEntries).toHaveLength(4);
expect(result.current.mapEntries[0]).toMatchObject({ id: 'stay-10', dayColor: '#ff0055', dayLabel: 1 });
expect(repo.takeOverDay).not.toHaveBeenCalled();
});
it('FE-JRN-DAYIMPORT-002: takes the chosen stays over, leaving the unchosen open one out, then reloads the journey and closes', async () => {
const { result, onTakenOver, onClose } = setup();
await waitFor(() => expect(result.current.choice).not.toBeNull());
act(() => result.current.toggle(10));
await act(async () => result.current.submit());
expect(repo.takeOverDay).toHaveBeenCalledWith(7, '2026-05-14', {
groups: [
{
kind: 'merged',
stays: [{ suggestionId: 11 }, { suggestionId: 12 }],
title: 'Piazza San Marco',
anchorSuggestionId: 12,
},
{ kind: 'single', stays: [{ suggestionId: 13 }] },
],
skipSuggestionIds: [10],
});
expect(toast.success).toHaveBeenCalledWith('1 draft taken over');
expect(onTakenOver).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
expect(repo.save).not.toHaveBeenCalled();
});
it('FE-JRN-DAYIMPORT-003: a failed take-over says nothing was taken over, reads the day again and keeps the names given', async () => {
const { result, onTakenOver, onClose } = setup();
await waitFor(() => expect(result.current.choice).not.toBeNull());
repo.takeOverDay.mockRejectedValue(httpError('stay_not_on_day'));
const stays = buildDayStays().stays;
act(() => result.current.rename(stays[3] as (typeof stays)[number], 'Ponte di Rialto'));
await act(async () => result.current.submit());
await waitFor(() => expect(repo.reviewDay).toHaveBeenCalledTimes(2));
expect(result.current.outcome?.kind).toBe('failed');
expect(result.current.choice?.names).toEqual({ 13: 'Ponte di Rialto' });
expect(onTakenOver).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it('FE-JRN-DAYIMPORT-004: the route goes along only when switched on, kept as previewed', async () => {
const { result, onKept, onClose } = setup();
await waitFor(() => expect(result.current.route.offer).toBe('keep'));
repo.save.mockResolvedValue({ day: buildRecordedDay(), outcome: 'created' });
act(() => result.current.route.toggle());
await act(async () => result.current.submit());
expect(repo.save).toHaveBeenCalledWith(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH });
expect(onKept).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('FE-JRN-DAYIMPORT-005: a route that fails after the drafts keeps the dialog open to keep the route alone', async () => {
const { result, onTakenOver, onClose } = setup();
await waitFor(() => expect(result.current.route.offer).toBe('keep'));
repo.save.mockRejectedValueOnce(httpError('recording_changed'));
act(() => result.current.route.toggle());
await act(async () => result.current.submit());
expect(onTakenOver).toHaveBeenCalledTimes(1);
expect(result.current.outcome?.kind).toBe('route');
expect(onClose).not.toHaveBeenCalled();
repo.save.mockResolvedValue({ day: buildRecordedDay(), outcome: 'created' });
await act(async () => result.current.retryRoute());
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
expect(repo.takeOverDay).toHaveBeenCalledTimes(1);
});
it('FE-JRN-DAYIMPORT-006: a day whose route is kept offers no second copy, and stays held already are announced', async () => {
repo.takeOverDay.mockResolvedValue({ entries: [], alreadyTaken: [11, 12], skipped: [] });
const { result } = setup({ storedRoute: buildRecordedDay() });
await waitFor(() => expect(result.current.loading).toBe(false));
await waitFor(() => expect(result.current.route.offer).toBe('kept'));
await act(async () => result.current.submit());
expect(toast.success).not.toHaveBeenCalled();
expect(toast.info).toHaveBeenCalledWith('2 stays were taken over already. Nothing was written twice.');
expect(repo.save).not.toHaveBeenCalled();
});
it('FE-JRN-DAYIMPORT-007: nothing chosen holds the take-over back, and a review that fails says why', async () => {
const { result } = setup();
await waitFor(() => expect(result.current.choice).not.toBeNull());
act(() => {
for (const id of [10, 11, 12, 13]) result.current.toggle(id);
});
expect(result.current.problem).toBe('nothing');
await act(async () => result.current.submit());
expect(repo.takeOverDay).not.toHaveBeenCalled();
repo.reviewDay.mockRejectedValue(httpError('unreachable'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.error).not.toBeNull());
});
it('FE-JRN-DAYIMPORT-008: today is marked as still running, a past day is not (JRN-03)', async () => {
const past = setup();
await waitFor(() => expect(past.result.current.loading).toBe(false));
expect(past.result.current.running).toBe(false);
past.unmount();
const today = setup({ date: localIsoDate() });
await waitFor(() => expect(today.result.current.loading).toBe(false));
expect(today.result.current.running).toBe(true);
});
});
@@ -0,0 +1,344 @@
import type {
JourneyDayImportResult,
JourneyDayStay,
JourneyDayStays,
JourneyRecordedDay,
JourneyRecordingPreview,
JourneyTrack,
} from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { journeyRecordingsRepo } from '../../repo/journeyRecordingsRepo';
import { localIsoDate } from '../../utils/localDate';
import { useToast } from '../shared/Toast';
import {
anchorOf,
groupsOf,
initialChoice,
isTakeable,
nameOf,
problemOf,
rename,
requestOf,
toggleJoin,
toggleStay,
type DayImportChoice,
} from './journeyDayImportModel';
import { recordingErrorText } from './journeyRecordingModel';
import { useJourneyRecordingDay } from './useJourneyRecordingDay';
import type { JourneyRecordingPrepState } from './useJourneyRecordingPrep';
/** What the desktop dialog and the phone sheet are opened with. */
export interface JourneyDayImportViewProps {
journeyId: number;
journeyTitle: string;
date: string;
/** The colour of the day's pins on the journey map, so its stays read as that day. */
dayColor: string;
/** The route the journey keeps for the day, if any. */
storedRoute: JourneyRecordedDay | undefined;
/** Drafts were written: the journey and the stay list are read again. */
onTakenOver: () => void;
onKept: (day: JourneyRecordedDay) => void;
onClose: () => void;
/** The caller's own preparation of the journey's days (JRN-03), for editors too; absent while it is off or unread. */
prep?: JourneyRecordingPrepState;
}
/**
* Something went wrong on the way. `failed`: nothing was taken over, the list
* shows what the journal holds now and sending again writes nothing twice.
* `route`: the drafts are in, only the route was not kept.
*/
type Outcome = { kind: 'failed' | 'route'; reason: string };
type RouteOffer = 'loading' | 'kept' | 'keep' | 'differs' | 'none';
const noop = () => {};
/** The map marker id of a stay, so a pin and its row find each other. */
export function stayMarkerId(suggestionId: number): string {
return `stay-${suggestionId}`;
}
/**
* The day's stays from the traveller's own Dawarich and what they chose of
* them. A reload of the same day keeps the choice, so names and joins survive
* a failed attempt; the last answer stays shown while the next one is read.
*/
function useDayReview(journeyId: number, date: string) {
const [reloadToken, setReloadToken] = useState(0);
const forDay = `${journeyId}|${date}`;
const requestKey = `${forDay}|${reloadToken}`;
const [answer, setAnswer] = useState<{ key: string; review: JourneyDayStays | null; failure: unknown }>(NO_ANSWER);
const [chosen, setChosen] = useState<{ forDay: string; choice: DayImportChoice } | null>(null);
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
journeyRecordingsRepo
.reviewDay(journeyId, date, controller.signal)
.then((review) => {
if (cancelled) return;
setAnswer({ key: requestKey, review, failure: null });
setChosen((prev) => (prev?.forDay === forDay ? prev : { forDay, choice: initialChoice(review.stays) }));
})
.catch((failure: unknown) => {
if (!cancelled) setAnswer({ key: requestKey, review: null, failure });
});
return () => {
cancelled = true;
controller.abort();
};
}, [journeyId, date, forDay, requestKey]);
const update = useCallback(
(change: (choice: DayImportChoice) => DayImportChoice) =>
setChosen((prev) => (prev ? { ...prev, choice: change(prev.choice) } : prev)),
[]
);
return {
review: answer.review,
loading: answer.key !== requestKey,
failure: answer.key === requestKey ? answer.failure : null,
choice: chosen?.forDay === forDay ? chosen.choice : null,
update,
reload: useCallback(() => setReloadToken((n) => n + 1), []),
};
}
/** What the dialog offers for the day's route: kept already, kept in another version, to keep, or nothing recorded. */
function routeOfferOf(
stored: JourneyRecordedDay | undefined,
route: ReturnType<typeof useJourneyRecordingDay>
): RouteOffer {
if (stored) return route.action === 'replace' ? 'differs' : 'kept';
if (route.loading) return 'loading';
return route.action === 'keep' ? 'keep' : 'none';
}
/** The stays as numbered pins in the day's colour, unchosen ones in the map's neutral pin, and the route under them. */
function useDayMap(
stays: JourneyDayStay[],
choice: DayImportChoice | null,
date: string,
dayColor: string,
route: JourneyRecordedDay | JourneyRecordingPreview | null
) {
const { t } = useTranslation();
const entries = useMemo(
() =>
stays.flatMap((stay, index) =>
stay.lat === null || stay.lng === null
? []
: [
{
id: stayMarkerId(stay.suggestionId),
lat: stay.lat,
lng: stay.lng,
title: choice ? nameOf(choice, stay) : stay.name,
entry_date: date,
dayColor: choice?.selected.has(stay.suggestionId) && isTakeable(stay) ? dayColor : undefined,
dayLabel: index + 1,
},
]
),
[stays, choice, date, dayColor]
);
const tracks = useMemo<JourneyTrack[]>(
() =>
(route?.segments ?? []).map((segment, index) => ({
place_id: -(index + 1),
trip_id: 0,
name: t('journey.recording.scope'),
color: dayColor,
points: segment.points,
kind: 'day_recording',
})),
[route, dayColor, t]
);
return { entries, tracks };
}
/**
* The take-over and, when asked, the route after it. A take-over that fails
* leaves nothing written and reads the day again; a route that fails after
* the drafts are in keeps the dialog open to try the route alone.
*/
function useDayTakeOver({
journeyId,
date,
preview,
onTakenOver,
onKept,
onClose,
reload,
}: {
journeyId: number;
date: string;
preview: JourneyRecordingPreview | null;
onTakenOver: () => void;
onKept: (day: JourneyRecordedDay) => void;
onClose: () => void;
reload: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const [busy, setBusy] = useState<'submit' | 'route' | null>(null);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const saveRoute = useCallback(async (): Promise<boolean> => {
if (!preview?.contentHash) return true;
try {
const saved = await journeyRecordingsRepo.save(journeyId, date, {
utcOffsetMinutes: preview.utcOffsetMinutes,
expectedHash: preview.contentHash,
});
onKept(saved.day);
return true;
} catch (err) {
setOutcome({ kind: 'route', reason: recordingErrorText(err, t) });
return false;
}
}, [preview, journeyId, date, onKept, t]);
const announce = useCallback(
(result: JourneyDayImportResult) => {
if (result.entries.length > 0) toast.success(t('journey.dayImport.done', { count: result.entries.length }));
if (result.alreadyTaken.length > 0) {
toast.info(t('journey.dayImport.alreadyTaken', { count: result.alreadyTaken.length }));
}
},
[toast, t]
);
const submit = useCallback(
async (request: Parameters<typeof journeyRecordingsRepo.takeOverDay>[2], withRoute: boolean) => {
setBusy('submit');
setOutcome(null);
try {
announce(await journeyRecordingsRepo.takeOverDay(journeyId, date, request));
onTakenOver();
if (!withRoute || (await saveRoute())) onClose();
else reload();
} catch (err) {
setOutcome({ kind: 'failed', reason: recordingErrorText(err, t) });
// The answer may be lost after the write: read again, so the list says what the journal holds.
reload();
} finally {
setBusy(null);
}
},
[journeyId, date, announce, onTakenOver, saveRoute, onClose, reload, t]
);
const retryRoute = useCallback(async () => {
setBusy('route');
setOutcome(null);
const kept = await saveRoute();
setBusy(null);
if (!kept) return;
toast.success(t('journey.recording.keptToast'));
onClose();
}, [saveRoute, toast, t, onClose]);
return { busy, outcome, submit, retryRoute };
}
/**
* Taking a recorded day into the journal with its stays (JRN-02), for the
* desktop dialog and the phone sheet alike: the day's stays read from the
* traveller's own Dawarich, the choice of which become drafts under which
* names and in which groups, the day's route on the same map, and the
* take-over itself. Nothing is written before the button; cancelling leaves
* everything as it was.
*/
export function useJourneyDayImport({
journeyId,
date,
dayColor,
storedRoute,
onTakenOver,
onKept,
onClose,
}: Omit<JourneyDayImportViewProps, 'journeyTitle' | 'prep'>) {
const { t, locale } = useTranslation();
const day = useDayReview(journeyId, date);
const route = useJourneyRecordingDay({ journeyId, date, onKept, onRemoved: noop, onClose: noop });
const [keepRoute, setKeepRoute] = useState(false);
const [activeStay, setActiveStay] = useState<number | null>(null);
const { choice, update } = day;
const stays = useMemo(() => day.review?.stays ?? [], [day.review]);
const reportDefault = t('journey.dayImport.reportDefault', {
date: new Date(`${date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
});
const routeOffer = routeOfferOf(storedRoute, route);
const map = useDayMap(
stays,
choice,
date,
dayColor,
storedRoute ?? (route.preview?.contentHash ? route.preview : null)
);
const takeOver = useDayTakeOver({
journeyId,
date,
preview: route.preview,
onTakenOver,
onKept,
onClose,
reload: day.reload,
});
const groups = useMemo(() => (choice ? groupsOf(choice, stays) : []), [choice, stays]);
const problem = choice ? problemOf(choice, stays, reportDefault) : 'nothing';
return {
date,
review: day.review,
loading: day.loading,
/** The day is today: stays recorded later still arrive. */
running: date === localIsoDate(),
/** Translated; null while nothing failed. */
error: day.failure === null ? null : recordingErrorText(day.failure, t),
retry: day.reload,
choice,
stays,
groups,
problem,
reportDefault,
skipCount: stays.filter((stay) => stay.status === 'open' && !choice?.selected.has(stay.suggestionId)).length,
activeStay,
setActiveStay,
mapEntries: map.entries,
mapTracks: map.tracks,
route: {
offer: routeOffer,
distanceMeters: (storedRoute ?? route.preview)?.stats.distanceMeters ?? null,
keep: keepRoute,
toggle: () => setKeepRoute((on) => !on),
},
busy: takeOver.busy,
outcome: takeOver.outcome,
submit: () => {
if (choice && !problem) {
void takeOver.submit(requestOf(choice, stays, reportDefault), keepRoute && routeOffer === 'keep');
}
},
retryRoute: () => void takeOver.retryRoute(),
toggle: (id: number) => update((c) => toggleStay(c, id)),
join: (id: number) => update((c) => toggleJoin(c, id)),
rename: (stay: JourneyDayStay, name: string) => update((c) => rename(c, stay, name)),
setMode: (mode: DayImportChoice['mode']) => update((c) => ({ ...c, mode })),
setTitle: (key: number, title: string) => update((c) => ({ ...c, titles: { ...c.titles, [key]: title } })),
setAnchor: (key: number, id: number | null) => update((c) => ({ ...c, anchors: { ...c.anchors, [key]: id } })),
anchorOf: (key: number) => {
const group = groups.find((g) => g.key === key);
return group && choice ? anchorOf(choice, group) : null;
},
};
}
export type JourneyDayImportState = ReturnType<typeof useJourneyDayImport>;
const NO_ANSWER = { key: '', review: null, failure: null };
@@ -0,0 +1,196 @@
// FE-JOURNEY-MAPLAYERS-HOOK-001 to FE-JOURNEY-MAPLAYERS-HOOK-005
//
// JRN-04: the journey map's layers as the desktop page, the phone screen and
// the shared page use them: read lazily, switched per reader, a failed source
// on its own.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { JourneyMapPlace, JourneyTrack } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { journeyMapRepo } from '../../repo/journeyMapRepo';
import { useJourneyMapLayers } from './useJourneyMapLayers';
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (k: string, p?: Record<string, string>) => (p?.date ? `${k}:${p.date}` : k),
locale: 'en',
}),
}));
const PLACE: JourneyMapPlace = {
place_id: 4,
trip_id: 2,
trip_title: 'Venice',
name: 'Piazza San Marco',
lat: 45.434,
lng: 12.3388,
days: ['2026-05-14'],
time: null,
category_color: null,
category_icon: null,
entry_id: null,
};
const ROUTE: JourneyTrack = {
place_id: 4,
trip_id: 2,
name: 'Ridge',
color: null,
points: [
[1, 1],
[2, 2],
],
kind: 'trip_gpx',
};
const NO_ENTRIES: ReadonlySet<string> = new Set();
const colorOfDay = () => '#14b8a6';
const dateOfEntry = () => '2026-05-14';
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
function journeyMap(journeyId: number | null = 7, recordedTracks: JourneyTrack[] = []) {
return renderHook(() =>
useJourneyMapLayers({
source: journeyId === null ? null : { journeyId },
entryIdsOnMap: NO_ENTRIES,
photoCount: 2,
recordedTracks,
colorOfDay,
dateOfEntry,
})
);
}
describe('useJourneyMapLayers', () => {
it('FE-JOURNEY-MAPLAYERS-HOOK-001: reads the journey layers once it is loaded and draws the plan with its routes', async () => {
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
places: { status: 'ok', items: [PLACE] },
routes: { status: 'ok', items: [ROUTE] },
stays: { status: 'ok', items: [] },
});
const { result } = journeyMap();
await waitFor(() => expect(result.current.marks).toHaveLength(1));
expect(layers).toHaveBeenCalledWith(7, expect.any(AbortSignal));
expect(result.current.tracks).toEqual([ROUTE]);
expect(result.current.controls.find((c) => c.layer === 'plan')).toEqual({
layer: 'plan',
count: 1,
state: 'ready',
});
expect(result.current.controls.find((c) => c.layer === 'photos')).toEqual({
layer: 'photos',
count: 2,
state: 'ready',
});
});
it('FE-JOURNEY-MAPLAYERS-HOOK-002: reads nothing before the journey is loaded, nor while plan and entries are off', async () => {
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
places: { status: 'ok', items: [] },
routes: { status: 'ok', items: [] },
stays: { status: 'ok', items: [] },
});
journeyMap(null);
localStorage.setItem('trek:journey-map-layers:8', JSON.stringify({ plan: false, entries: false }));
const { result } = journeyMap(8);
expect(result.current.visibility).toMatchObject({ plan: false, entries: false, recorded: true, photos: true });
expect(result.current.controls.find((c) => c.layer === 'plan')?.state).toBe('idle');
await act(async () => {});
expect(layers).not.toHaveBeenCalled();
act(() => result.current.toggle('plan'));
await waitFor(() => expect(layers).toHaveBeenCalledWith(8, expect.any(AbortSignal)));
expect(JSON.parse(localStorage.getItem('trek:journey-map-layers:8') ?? '{}')).toMatchObject({
plan: true,
entries: false,
});
});
it('FE-JOURNEY-MAPLAYERS-HOOK-003: a read that fails says so on the plan and the entries, and the recordings still draw', async () => {
vi.spyOn(journeyMapRepo, 'layers').mockRejectedValue(new Error('down'));
const recorded: JourneyTrack = {
place_id: -100001,
trip_id: 0,
name: 'Day 1',
color: '#000000',
points: [
[1, 1],
[2, 2],
],
kind: 'day_recording',
};
const { result } = journeyMap(7, [recorded]);
await waitFor(() => expect(result.current.controls.find((c) => c.layer === 'plan')?.state).toBe('unavailable'));
expect(result.current.controls.find((c) => c.layer === 'entries')?.state).toBe('unavailable');
expect(result.current.controls.find((c) => c.layer === 'recorded')).toEqual({
layer: 'recorded',
count: 1,
state: 'ready',
});
expect(result.current.tracks).toEqual([recorded]);
});
it('FE-JOURNEY-MAPLAYERS-HOOK-004: a shared page draws the recorded days a link shares, each piece apart, and offers no plan it withholds', async () => {
vi.spyOn(journeyMapRepo, 'publicLayers').mockResolvedValue({
places: { status: 'withheld' },
routes: { status: 'withheld' },
stays: { status: 'ok', items: [] },
recordings: {
status: 'ok',
items: [
{
date: '2026-05-14',
pieces: [
[
[1, 1],
[2, 2],
],
[
[3, 3],
[4, 4],
],
],
distance_m: 1200,
},
],
},
});
const { result } = renderHook(() =>
useJourneyMapLayers({
source: { token: 'tok' },
entryIdsOnMap: NO_ENTRIES,
photoCount: 0,
colorOfDay,
dateOfEntry,
})
);
await waitFor(() => expect(result.current.tracks).toHaveLength(2));
expect(result.current.tracks.every((track) => track.kind === 'day_recording' && track.color === '#14b8a6')).toBe(
true
);
expect(result.current.controls.map((c) => c.layer)).toEqual(['recorded', 'entries', 'photos']);
expect(result.current.controls[0]).toEqual({ layer: 'recorded', count: 1, state: 'ready' });
});
it('FE-JOURNEY-MAPLAYERS-HOOK-005: switching the plan off takes its marks away without reading again', async () => {
const layers = vi.spyOn(journeyMapRepo, 'layers').mockResolvedValue({
places: { status: 'ok', items: [PLACE] },
routes: { status: 'ok', items: [] },
stays: { status: 'ok', items: [] },
});
const { result } = journeyMap();
await waitFor(() => expect(result.current.marks).toHaveLength(1));
act(() => result.current.toggle('plan'));
expect(result.current.marks).toHaveLength(0);
expect(result.current.visibility.plan).toBe(false);
expect(layers).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,212 @@
import type { JourneyTrack } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { journeyMapRepo } from '../../repo/journeyMapRepo';
import { DAWARICH_TRAIL_COLOR } from '../Map/dawarichTrail';
import {
ALL_VISIBLE,
buildControls,
buildMarks,
itemsOf,
layersStorageKey,
readStoredLayers,
sourcesOf,
writeStoredLayers,
type JourneyMapAnswer,
type JourneyMapLayer,
type JourneyMapLayerControl,
type JourneyMapVisibility,
} from './journeyMapLayers';
import type { JourneyMapMark } from './journeyMapMarks';
/** Whose map it is: a journey its reader belongs to, or one a share link opens. */
type JourneyMapSource = { journeyId: number } | { token: string };
export interface JourneyMapLayersView {
visibility: JourneyMapVisibility;
toggle: (layer: JourneyMapLayer) => void;
/** The switches the reader is offered: a layer a share link does not show is not among them. */
controls: JourneyMapLayerControl[];
/** The plan's routes and what was recorded, each kind hidden by its own switch on the map. */
tracks: JourneyTrack[];
marks: JourneyMapMark[];
/** Entry pins whose place the plan had too: they carry the plan's check. */
plannedEntryIds: ReadonlySet<string>;
}
interface Options {
/** Null until the journey the screen names is the one loaded. */
source: JourneyMapSource | null;
/** Changes when entries come or go, so a place's entry and a report's stays follow. */
refreshKey?: string;
/** The entries that have a pin on the map. */
entryIdsOnMap: ReadonlySet<string>;
photoCount: number;
/** What was recorded that the screen already holds: the kept days and the live trail of a journey's people. */
recordedTracks?: JourneyTrack[];
/** How reading those went: a failed read is not a journey with nothing recorded. */
recordedRead?: 'loading' | 'ready' | 'unavailable';
/** The colour of the pins of a day, so a stay and a recorded day read with their day. */
colorOfDay: (date: string) => string | null;
/** The day an entry is on, for the colour of its stays. */
dateOfEntry: (entryId: number) => string | null;
}
const NO_TRACKS: JourneyTrack[] = [];
/** The reader's switches, kept per journey (or per link) in this browser. */
function useLayerSwitches(storageKey: string | null) {
const [stored, setStored] = useState<{ key: string | null; visibility: JourneyMapVisibility } | null>(null);
const visibility = useMemo(() => {
if (stored && stored.key === storageKey) return stored.visibility;
return storageKey ? readStoredLayers(storageKey) : ALL_VISIBLE;
}, [stored, storageKey]);
const toggle = useCallback(
(layer: JourneyMapLayer) => {
const next = { ...visibility, [layer]: !visibility[layer] };
setStored({ key: storageKey, visibility: next });
if (storageKey) writeStoredLayers(storageKey, next);
},
[visibility, storageKey]
);
return { visibility, toggle };
}
/**
* The layers read from the server, once per `requestKey`. What was read for
* an earlier key of the same map stays until the new answer lands; null is a
* read that failed.
*/
function useLayersRead(
journeyId: number | null,
token: string | null,
requestKey: string | null,
sourceKey: string | null
) {
const [loaded, setLoaded] = useState<{ key: string; layers: JourneyMapAnswer | null } | null>(null);
useEffect(() => {
if (!requestKey) return;
const controller = new AbortController();
const read =
journeyId !== null
? journeyMapRepo.layers(journeyId, controller.signal)
: journeyMapRepo.publicLayers(token ?? '', controller.signal);
read
.then((layers) => setLoaded({ key: requestKey, layers }))
.catch(() => {
if (!controller.signal.aborted) setLoaded({ key: requestKey, layers: null });
});
return () => controller.abort();
}, [requestKey, journeyId, token]);
const layers = loaded && sourceKey && loaded.key.startsWith(`${sourceKey}|`) ? loaded.layers : undefined;
return { layers, loading: !!requestKey && loaded?.key !== requestKey };
}
/**
* The layers of a journey map (JRN-04) for the desktop page, the phone screen
* and the shared page alike: the reader's switches, kept per journey in this
* browser, the plan and the stays read from the server, and what the
* renderers draw from them. Nothing is read while neither the plan nor the
* entries are shown; a source that fails says so on its switch and the
* others keep drawing.
*/
export function useJourneyMapLayers({
source,
refreshKey = '',
entryIdsOnMap,
photoCount,
recordedTracks = NO_TRACKS,
recordedRead = 'ready',
colorOfDay,
dateOfEntry,
}: Options): JourneyMapLayersView {
const { t, locale } = useTranslation();
const journeyId = source && 'journeyId' in source ? source.journeyId : null;
const token = source && 'token' in source ? source.token : null;
const storageKey =
journeyId !== null ? layersStorageKey({ journeyId }) : token !== null ? layersStorageKey({ token }) : null;
const { visibility, toggle } = useLayerSwitches(storageKey);
const sourceKey = journeyId !== null ? `j:${journeyId}` : token !== null ? `s:${token}` : null;
// A shared page reads once; a journey's own map only while the plan or the entries are shown.
const wanted = token !== null || (journeyId !== null && (visibility.plan || visibility.entries));
const requestKey = wanted && sourceKey ? `${sourceKey}|${refreshKey}` : null;
const { layers, loading } = useLayersRead(journeyId, token, requestKey, sourceKey);
const sources = useMemo(() => sourcesOf(layers), [layers]);
const places = useMemo(() => itemsOf(sources.places), [sources.places]);
const routes = useMemo(() => itemsOf(sources.routes), [sources.routes]);
const stays = useMemo(() => itemsOf(sources.stays), [sources.stays]);
const sharedRecorded = useMemo(
() =>
itemsOf(sources.recordings).flatMap((day, dayIndex) => {
const name = t('journey.recording.mapLine', {
date: new Date(`${day.date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
});
const color = colorOfDay(day.date) ?? DAWARICH_TRAIL_COLOR;
return day.pieces.map((points, index): JourneyTrack => ({
// Negative and far from the live trail's ids, as the journey's own kept days.
place_id: -(100_000 + dayIndex * 1_000 + index),
trip_id: 0,
name,
color,
points,
kind: 'day_recording',
}));
}),
[sources.recordings, colorOfDay, t, locale]
);
const recorded = recordedTracks.length ? recordedTracks : sharedRecorded;
const tracks = useMemo(
() => (routes.length === 0 ? recorded : recorded.length === 0 ? routes : [...routes, ...recorded]),
[routes, recorded]
);
const stayColor = useCallback(
(entryId: number) => {
const date = dateOfEntry(entryId);
return date ? colorOfDay(date) : null;
},
[dateOfEntry, colorOfDay]
);
const { marks, plannedEntryIds } = useMemo(
() => buildMarks({ places, stays, visibility, entryIdsOnMap, stayColor }),
[places, stays, visibility, entryIdsOnMap, stayColor]
);
const recordedDays = useMemo(() => new Set(recorded.map((track) => track.name)).size, [recorded]);
const controls = useMemo(
() =>
buildControls({
ownJourney: journeyId !== null,
sources,
loading,
recordedRead,
counts: {
places: places.length,
routes: routes.length,
recordedDays,
entries: entryIdsOnMap.size,
stays: stays.length,
photos: photoCount,
},
}),
[
journeyId,
sources,
loading,
recordedRead,
places.length,
routes.length,
recordedDays,
entryIdsOnMap.size,
stays.length,
photoCount,
]
);
return { visibility, toggle, controls, tracks, marks, plannedEntryIds };
}
@@ -0,0 +1,173 @@
// FE-JRN-RECORDING-DAY-001 to FE-JRN-RECORDING-DAY-008
//
// JRN-01: taking one day's recorded route over, for the desktop dialog and the
// phone sheet alike. Nothing is kept before the button, what is kept is the
// preview that was shown, and a recording that changed in between is shown
// again instead of kept unseen.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildRecordedDay, buildRecordingPreview } from '../../../tests/helpers/journeyRecording';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { TranslationProvider } from '../../i18n/TranslationContext';
import { OnlineOnlyError } from '../../repo/onlineOnly';
import { useJourneyRecordingDay } from './useJourneyRecordingDay';
const repo = vi.hoisted(() => ({ preview: vi.fn(), save: vi.fn(), remove: vi.fn() }));
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const HASH = 'a'.repeat(64);
const httpError = (code: string) => Object.assign(new Error(code), { response: { data: { error: code, code } } });
function setup() {
const onKept = vi.fn();
const onRemoved = vi.fn();
const onClose = vi.fn();
// Wrapped, so the strings are the English ones and not their keys.
const view = renderHook(
() => useJourneyRecordingDay({ journeyId: 7, date: '2026-05-14', onKept, onRemoved, onClose }),
{ wrapper: TranslationProvider }
);
return { ...view, onKept, onRemoved, onClose };
}
beforeEach(() => {
for (const fn of [...Object.values(repo), ...Object.values(toast)]) fn.mockReset();
});
describe('useJourneyRecordingDay', () => {
it('FE-JRN-RECORDING-DAY-001: reads the day in the zone Dawarich records in first, and offers to keep it', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview());
const { result } = setup();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(repo.preview).toHaveBeenCalledWith(
7,
{
date: '2026-05-14',
utcOffsetMinutes: -new Date('2026-05-14T12:00:00').getTimezoneOffset(),
useSourceOffset: true,
},
expect.any(AbortSignal)
);
expect(result.current.action).toBe('keep');
expect(result.current.zone).toBe(120);
expect(result.current.zoneOptions.find((o) => o.value === 120)?.badge).toBe('From Dawarich');
expect(repo.save).not.toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DAY-002: another zone reads the day again, cut in that zone', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview());
const { result } = setup();
await waitFor(() => expect(result.current.loading).toBe(false));
repo.preview.mockResolvedValue(buildRecordingPreview({ utcOffsetMinutes: -300, offsetSource: 'chosen' }));
act(() => result.current.chooseZone(-300));
await waitFor(() => expect(result.current.zone).toBe(-300));
expect(repo.preview).toHaveBeenLastCalledWith(
7,
{ date: '2026-05-14', utcOffsetMinutes: -300, useSourceOffset: false },
expect.any(AbortSignal)
);
});
it('FE-JRN-RECORDING-DAY-003: keeps what was previewed, in the zone it was cut in, then closes', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview());
const day = buildRecordedDay();
repo.save.mockResolvedValue({ day, outcome: 'created' });
const { result, onKept, onClose } = setup();
await waitFor(() => expect(result.current.action).toBe('keep'));
await act(() => result.current.save());
expect(repo.save).toHaveBeenCalledWith(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH });
expect(onKept).toHaveBeenCalledWith(day);
expect(toast.success).toHaveBeenCalledWith('Route kept in the journey');
expect(onClose).toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DAY-004: replaces a different kept version only through its own action, and keeps nothing twice', async () => {
const stored = buildRecordedDay({ contentHash: 'b'.repeat(64) });
repo.preview.mockResolvedValue(buildRecordingPreview({ stored }));
repo.save.mockResolvedValue({ day: buildRecordedDay(), outcome: 'replaced' });
const { result } = setup();
await waitFor(() => expect(result.current.action).toBe('replace'));
await act(() => result.current.save());
expect(repo.save).toHaveBeenCalledWith(7, '2026-05-14', {
utcOffsetMinutes: 120,
expectedHash: HASH,
replace: true,
});
repo.save.mockClear();
repo.preview.mockResolvedValue(buildRecordingPreview({ stored: buildRecordedDay(), sameAsStored: true }));
act(() => result.current.retry());
await waitFor(() => expect(result.current.action).toBe('same'));
await act(() => result.current.save());
expect(repo.save).not.toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DAY-005: a recording that changed since the preview is read again and said so, not kept', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview());
repo.save.mockRejectedValue(httpError('recording_changed'));
const { result, onKept, onClose } = setup();
await waitFor(() => expect(result.current.action).toBe('keep'));
await act(() => result.current.save());
expect(result.current.changedSincePreview).toBe(true);
await waitFor(() => expect(repo.preview).toHaveBeenCalledTimes(2));
expect(onKept).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DAY-008: a day someone else kept meanwhile is read again and offers Replace, not a Keep that fails', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview());
repo.save.mockRejectedValue(httpError('recording_exists'));
const { result, onKept, onClose } = setup();
await waitFor(() => expect(result.current.action).toBe('keep'));
repo.preview.mockResolvedValue(
buildRecordingPreview({ stored: buildRecordedDay({ contentHash: 'b'.repeat(64) }) })
);
await act(() => result.current.save());
expect(toast.error).toHaveBeenCalledWith('This journey already keeps another recording of that day.');
await waitFor(() => expect(result.current.action).toBe('replace'));
expect(repo.preview).toHaveBeenCalledTimes(2);
expect(result.current.changedSincePreview).toBe(false);
expect(onKept).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it('FE-JRN-RECORDING-DAY-006: says why the day could not be read, and what was recorded nothing of has nothing to keep', async () => {
repo.preview.mockRejectedValueOnce(httpError('not_connected'));
const { result } = setup();
await waitFor(() => expect(result.current.error).toBe('No Dawarich instance is connected yet.'));
expect(result.current.action).toBeNull();
repo.preview.mockResolvedValue(buildRecordingPreview({ contentHash: null, segments: [] }));
act(() => result.current.retry());
await waitFor(() => expect(result.current.action).toBe('empty'));
expect(result.current.error).toBeNull();
});
it('FE-JRN-RECORDING-DAY-007: drops the kept day, and a failed drop or keep says why and stays open', async () => {
repo.preview.mockResolvedValue(buildRecordingPreview({ stored: buildRecordedDay(), sameAsStored: true }));
repo.remove.mockResolvedValueOnce(undefined);
const { result, onRemoved, onClose } = setup();
await waitFor(() => expect(result.current.loading).toBe(false));
await act(() => result.current.remove());
expect(repo.remove).toHaveBeenCalledWith(7, '2026-05-14');
expect(onRemoved).toHaveBeenCalledWith('2026-05-14');
expect(onClose).toHaveBeenCalledTimes(1);
repo.remove.mockRejectedValueOnce(new OnlineOnlyError());
await act(() => result.current.remove());
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('connection'));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,155 @@
import type { JourneyRecordedDay, JourneyRecordingPreview } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { journeyRecordingsRepo } from '../../repo/journeyRecordingsRepo';
import { useToast } from '../shared/Toast';
import { deviceOffsetOn, recordingErrorCode, recordingErrorText, zoneOptions } from './journeyRecordingModel';
/** What the desktop dialog and the phone sheet are opened with. */
export interface JourneyRecordingViewProps {
journeyId: number;
journeyTitle: string;
date: string;
/** What the journey keeps for the day, if anything. */
stored: JourneyRecordedDay | undefined;
onKept: (day: JourneyRecordedDay) => void;
onRemoved: (date: string) => void;
onClose: () => void;
}
/**
* What the dialog offers for the day.
* `keep`: nothing kept yet. `replace`: a different version is kept. `same`: this
* very recording is kept already. `empty`: nothing was recorded in this zone.
*/
type RecordingAction = 'keep' | 'replace' | 'same' | 'empty';
/**
* Taking one day's recorded route over into a journey (JRN-01), for the
* desktop dialog and the phone sheet alike.
*
* The preview is read when the dialog opens, cut in Dawarich's own zone where
* it reveals one and in this device's otherwise, and again whenever another
* zone is picked. Nothing is kept before the traveller says so, and what is
* kept is the preview they saw: when Dawarich changed in between, the preview
* is read again and says why instead of keeping something unseen.
*/
export function useJourneyRecordingDay({
journeyId,
date,
onKept,
onRemoved,
onClose,
}: Omit<JourneyRecordingViewProps, 'journeyTitle' | 'stored'>) {
const { t } = useTranslation();
const toast = useToast();
const deviceOffset = useMemo(() => deviceOffsetOn(date), [date]);
const [zone, setZone] = useState({ minutes: deviceOffset, useSource: true });
const [changedSincePreview, setChangedSincePreview] = useState(false);
const [busy, setBusy] = useState<'save' | 'remove' | null>(null);
const [reloadToken, setReloadToken] = useState(0);
// The answer remembers which request it answers: while it is not the one
// asked last, the day is loading, without a flag reset inside the effect.
const [answer, setAnswer] = useState<{ key: string; preview: JourneyRecordingPreview | null; failure: unknown }>(
NO_ANSWER
);
const requestKey = `${journeyId}|${date}|${zone.minutes}|${zone.useSource}|${reloadToken}`;
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
journeyRecordingsRepo
.preview(journeyId, { date, utcOffsetMinutes: zone.minutes, useSourceOffset: zone.useSource }, controller.signal)
.then((preview) => {
if (!cancelled) setAnswer({ key: requestKey, preview, failure: null });
})
.catch((failure: unknown) => {
if (!cancelled) setAnswer({ key: requestKey, preview: null, failure });
});
return () => {
cancelled = true;
controller.abort();
};
}, [journeyId, date, zone, requestKey]);
const loading = answer.key !== requestKey;
const preview = loading ? null : answer.preview;
const failure = loading ? null : answer.failure;
const retry = useCallback(() => setReloadToken((n) => n + 1), []);
const chooseZone = useCallback((minutes: number) => {
setChangedSincePreview(false);
setZone({ minutes, useSource: false });
}, []);
const action: RecordingAction | null = !preview
? null
: preview.contentHash === null
? 'empty'
: preview.sameAsStored
? 'same'
: preview.stored
? 'replace'
: 'keep';
const save = useCallback(async () => {
if (!preview?.contentHash || (action !== 'keep' && action !== 'replace')) return;
setBusy('save');
setChangedSincePreview(false);
try {
const result = await journeyRecordingsRepo.save(journeyId, date, {
utcOffsetMinutes: preview.utcOffsetMinutes,
expectedHash: preview.contentHash,
...(action === 'replace' ? { replace: true } : {}),
});
onKept(result.day);
toast.success(t(action === 'replace' ? 'journey.recording.replacedToast' : 'journey.recording.keptToast'));
onClose();
} catch (err) {
const code = recordingErrorCode(err);
if (code === 'recording_changed') {
setChangedSincePreview(true);
retry();
} else {
toast.error(recordingErrorText(err, t));
// Someone kept the day meanwhile: read it again so the dialog shows
// their version and offers Replace instead of a Keep that cannot work.
if (code === 'recording_exists') retry();
}
} finally {
setBusy(null);
}
}, [preview, action, journeyId, date, onKept, onClose, toast, t, retry]);
const remove = useCallback(async () => {
setBusy('remove');
try {
await journeyRecordingsRepo.remove(journeyId, date);
onRemoved(date);
toast.success(t('journey.recording.removedToast'));
onClose();
} catch (err) {
toast.error(recordingErrorText(err, t));
} finally {
setBusy(null);
}
}, [journeyId, date, onRemoved, onClose, toast, t]);
return {
preview,
loading,
/** Translated; null while nothing failed. */
error: failure === null ? null : recordingErrorText(failure, t),
action,
busy,
changedSincePreview,
zone: zone.useSource && preview ? preview.utcOffsetMinutes : zone.minutes,
zoneOptions: zoneOptions(t, preview?.sourceOffsetMinutes ?? null, deviceOffset),
chooseZone,
retry,
save,
remove,
};
}
const NO_ANSWER = { key: '', preview: null, failure: null };
@@ -0,0 +1,154 @@
// FE-JRN-PREP-HOOK-001 to FE-JRN-PREP-HOOK-007
//
// JRN-03: a journey preparing its recorded days, as the journey page, the
// phone screen and both settings shells read it: the caller's status, the
// owner's switch that fetches at once when turned on, a fetch by hand, and
// where each day of the linked trips stands.
import type { JourneyRecordingPrep } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { useJourneyStore } from '../../store/journeyStore';
import { useJourneyRecordingPrep, type JourneyRecordingPrepOptions } from './useJourneyRecordingPrep';
const repo = vi.hoisted(() => ({ prep: vi.fn() }));
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
const dawarich = vi.hoisted(() => ({ syncNow: vi.fn() }));
vi.mock('../../repo/dawarichRepo', () => ({ dawarichRepo: dawarich }));
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const updateJourney = vi.fn(async (_id: number, _data: Record<string, unknown>) => {});
const PERIOD = new Set(['2026-05-14', '2026-05-15']);
function status(over: Partial<JourneyRecordingPrep> = {}): JourneyRecordingPrep {
return {
enabled: true,
connection: 'ready',
lastAttemptAt: '2026-05-16T08:00:00.000Z',
lastSuccessAt: '2026-05-16T08:00:00.000Z',
lastError: null,
...over,
};
}
function options(over: Partial<JourneyRecordingPrepOptions> = {}): JourneyRecordingPrepOptions {
return {
journeyId: 7,
enabled: true,
active: true,
periodDates: PERIOD,
onSwitched: vi.fn(),
onFetched: vi.fn(),
...over,
};
}
beforeEach(() => {
repo.prep.mockReset().mockResolvedValue(status());
dawarich.syncNow.mockReset().mockResolvedValue({ state: 'ok', created: 0, updated: 0, missing: 0 });
updateJourney.mockClear();
toast.error.mockClear();
toast.info.mockClear();
useJourneyStore.setState({ updateJourney });
});
describe('useJourneyRecordingPrep', () => {
it('FE-JRN-PREP-HOOK-001: reads the caller status and answers for each day of the linked trips', async () => {
const { result } = renderHook(() => useJourneyRecordingPrep(options()));
await waitFor(() => expect(result.current.prep).not.toBeNull());
expect(repo.prep).toHaveBeenCalledWith(7, expect.any(AbortSignal));
expect(result.current.statusLine?.tone).toBe('muted');
expect(result.current.canFetch).toBe(true);
expect(result.current.dayPrep('2026-05-14')?.state).toBe('complete');
expect(result.current.dayPrep('2026-05-20')).toBeNull();
});
it('FE-JRN-PREP-HOOK-002: reads nothing for a reader or without the addon', async () => {
const { result } = renderHook(() => useJourneyRecordingPrep(options({ active: false })));
await act(async () => {});
expect(repo.prep).not.toHaveBeenCalled();
expect(result.current.prep).toBeNull();
expect(result.current.statusLine).toBeNull();
expect(result.current.canFetch).toBe(false);
});
it('FE-JRN-PREP-HOOK-003: switching on saves the journey and fetches at once; switching off only saves', async () => {
repo.prep.mockResolvedValue(status({ enabled: false }));
const opts = options({ enabled: false });
const { result } = renderHook(() => useJourneyRecordingPrep(opts));
await waitFor(() => expect(result.current.prep).not.toBeNull());
await act(async () => {
await result.current.setEnabled(true);
});
expect(updateJourney).toHaveBeenCalledWith(7, { auto_prepare_recordings: true });
expect(opts.onSwitched).toHaveBeenCalledTimes(1);
expect(dawarich.syncNow).toHaveBeenCalledTimes(1);
expect(opts.onFetched).toHaveBeenCalledTimes(1);
await act(async () => {
await result.current.setEnabled(false);
});
expect(updateJourney).toHaveBeenLastCalledWith(7, { auto_prepare_recordings: false });
expect(dawarich.syncNow).toHaveBeenCalledTimes(1);
});
it('FE-JRN-PREP-HOOK-004: a switch that fails to save says so and fetches nothing', async () => {
updateJourney.mockRejectedValueOnce(new Error('down'));
const opts = options({ enabled: false });
const { result } = renderHook(() => useJourneyRecordingPrep(opts));
await waitFor(() => expect(result.current.prep).not.toBeNull());
await act(async () => {
await result.current.setEnabled(true);
});
expect(toast.error).toHaveBeenCalledWith('journey.settings.saveFailed');
expect(opts.onSwitched).not.toHaveBeenCalled();
expect(dawarich.syncNow).not.toHaveBeenCalled();
});
it('FE-JRN-PREP-HOOK-005: a fetch by hand reads the status again, and one that cannot reach Dawarich says so', async () => {
const opts = options();
const { result } = renderHook(() => useJourneyRecordingPrep(opts));
await waitFor(() => expect(repo.prep).toHaveBeenCalledTimes(1));
await act(async () => {
await result.current.fetchNow();
});
expect(opts.onFetched).toHaveBeenCalledTimes(1);
await waitFor(() => expect(repo.prep).toHaveBeenCalledTimes(2));
expect(result.current.fetching).toBe(false);
dawarich.syncNow.mockRejectedValueOnce(new Error('offline'));
await act(async () => {
await result.current.fetchNow();
});
expect(toast.error).toHaveBeenCalledWith('journey.prep.fetchFailed');
await waitFor(() => expect(repo.prep).toHaveBeenCalledTimes(3));
});
it('FE-JRN-PREP-HOOK-006: a fetch while the background run is busy says so instead of looking like nothing happened', async () => {
dawarich.syncNow.mockResolvedValue({ state: 'ok', created: 0, updated: 0, missing: 0, alreadyRunning: true });
const { result } = renderHook(() => useJourneyRecordingPrep(options()));
await waitFor(() => expect(result.current.prep).not.toBeNull());
await act(async () => {
await result.current.fetchNow();
});
expect(toast.info).toHaveBeenCalledWith('journey.prep.alreadyRunning');
});
it('FE-JRN-PREP-HOOK-007: a journey whose trips all ended out of reach marks no day and offers no fetch', async () => {
repo.prep.mockResolvedValue(status({ periods: [] }));
const { result } = renderHook(() => useJourneyRecordingPrep(options()));
await waitFor(() => expect(result.current.prep).not.toBeNull());
expect(result.current.dayPrep('2026-05-14')).toBeNull();
expect(result.current.canFetch).toBe(false);
expect(result.current.statusLine).toEqual({
text: 'journey.prep.outOfReach',
tone: 'warning',
});
});
});
@@ -0,0 +1,141 @@
import type { JourneyRecordingPrep } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { dawarichRepo } from '../../repo/dawarichRepo';
import { journeyRecordingsRepo } from '../../repo/journeyRecordingsRepo';
import { useJourneyStore } from '../../store/journeyStore';
import { useToast } from '../shared/Toast';
import {
dayPrepOf,
prepReachOf,
prepStatusLine,
type JourneyDayPrep,
type PrepStatusLine,
} from './journeyRecordingPrepModel';
export interface JourneyRecordingPrepOptions {
/** The journey the route names, or null while it loads. */
journeyId: number | null;
/** The journey's switch as last read, so a change reads the status again. */
enabled: boolean;
/** An editor with the Dawarich addon on: nobody else has anything of their own prepared. */
active: boolean;
/** The dates of the linked trips; only those the server says its runs reach are prepared. */
periodDates: ReadonlySet<string>;
/** The switch was saved: the journey is read again. */
onSwitched: () => void;
/** A fetch by hand came back: the stays waiting at the days are read again. */
onFetched: () => void;
}
export interface JourneyRecordingPrepState {
/** Null until read, and for anyone `active` leaves out. */
prep: JourneyRecordingPrep | null;
saving: boolean;
/** A fetch asked for by hand is running. */
fetching: boolean;
/** The line under the switch in the settings and in the day review; null while it is off or not read yet. */
statusLine: PrepStatusLine | null;
/** Whether a fetch by hand would reach anything. */
canFetch: boolean;
setEnabled: (next: boolean) => Promise<void>;
fetchNow: () => Promise<void>;
dayPrep: (date: string) => JourneyDayPrep | null;
}
/**
* A journey preparing its recorded days (JRN-03), for the journey page, the
* phone screen, both settings shells and the day review: the caller's own
* status, the owner's switch, a fetch by hand and where each day stands.
* Switching it on fetches at once, so the days fill without waiting for the
* next background run.
*/
export function useJourneyRecordingPrep({
journeyId,
enabled,
active,
periodDates,
onSwitched,
onFetched,
}: JourneyRecordingPrepOptions): JourneyRecordingPrepState {
const { t, locale } = useTranslation();
const toast = useToast();
const updateJourney = useJourneyStore((s) => s.updateJourney);
// Held with its journey, so a journey opened after another never shows the other's status.
const [loaded, setLoaded] = useState<{ journeyId: number | null; prep: JourneyRecordingPrep | null }>(NOTHING);
const [saving, setSaving] = useState(false);
const [fetching, setFetching] = useState(false);
const [readToken, setReadToken] = useState(0);
const prep = active && loaded.journeyId === journeyId ? loaded.prep : null;
useEffect(() => {
if (!active || journeyId === null) return;
const controller = new AbortController();
void readPrep(journeyId, controller.signal).then((next) => {
if (next && !controller.signal.aborted) setLoaded({ journeyId, prep: next });
});
return () => controller.abort();
}, [journeyId, active, enabled, readToken]);
const fetchNow = useCallback(async () => {
setFetching(true);
try {
const result = await dawarichRepo.syncNow();
if (result.alreadyRunning) toast.info(t('journey.prep.alreadyRunning'));
onFetched();
} catch {
toast.error(t('journey.prep.fetchFailed'));
} finally {
setFetching(false);
setReadToken((n) => n + 1);
}
}, [onFetched, toast, t]);
const setEnabled = useCallback(
async (next: boolean) => {
if (journeyId === null) return;
setSaving(true);
try {
await updateJourney(journeyId, { auto_prepare_recordings: next });
onSwitched();
} catch {
toast.error(t('journey.settings.saveFailed'));
return;
} finally {
setSaving(false);
}
if (next && prep?.connection !== 'not_connected') await fetchNow();
},
[journeyId, updateJourney, onSwitched, toast, t, prep?.connection, fetchNow]
);
const reach = useMemo(() => prepReachOf(prep, periodDates), [prep, periodDates]);
const prepared = reach.dates.size > 0;
const statusLine = useMemo(
() => (prep ? prepStatusLine(prep, { prepared, outOfReach: reach.outOfReach }, t, locale) : null),
[prep, prepared, reach.outOfReach, t, locale]
);
const dayPrep = useCallback((date: string) => dayPrepOf(date, prep, reach.dates), [prep, reach.dates]);
return {
prep,
saving,
fetching,
statusLine,
canFetch: !!prep?.enabled && prep.connection !== 'not_connected' && prepared,
setEnabled,
fetchNow,
dayPrep,
};
}
/** The status, or null when it could not be read: the last one read stays, a status line is no reason for a toast. */
async function readPrep(journeyId: number, signal: AbortSignal): Promise<JourneyRecordingPrep | null> {
try {
return await journeyRecordingsRepo.prep(journeyId, signal);
} catch {
return null;
}
}
const NOTHING = { journeyId: null, prep: null };
@@ -0,0 +1,130 @@
// FE-JRN-RECORDINGS-HOOK-001 to FE-JRN-RECORDINGS-HOOK-006
//
// JRN-01: the recorded days a journey keeps, as the desktop page and the phone
// screen read them: the list, the lines they draw on the map, which day's
// dialog is open, and a fresh read whenever somebody keeps or drops a day.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildRecordedDay, WALK_SEGMENT } from '../../../tests/helpers/journeyRecording';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { useJourneyRecordings } from './useJourneyRecordings';
const repo = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock('../../repo/journeyRecordingsRepo', () => ({ journeyRecordingsRepo: repo }));
const pings = vi.hoisted(() => ({ handlers: new Map<string, () => Promise<void>>() }));
vi.mock('../../sync/useServerPing', () => ({
useServerPing: (event: string, enabled: boolean, reload: () => Promise<void>) => {
if (enabled) pings.handlers.set(event, reload);
},
}));
const JOURNEY = { id: 7, title: 'Venice' };
const colorOf = (date: string) => (date === '2026-05-14' ? '#ff0055' : null);
beforeEach(() => {
repo.list.mockReset();
pings.handlers.clear();
});
describe('useJourneyRecordings', () => {
it('FE-JRN-RECORDINGS-HOOK-001: reads the kept days and draws each piece as a recorded line in its day’s colour', async () => {
const second = {
...WALK_SEGMENT,
points: [
[45.44, 12.33],
[45.45, 12.34],
] as Array<[number, number]>,
};
repo.list.mockResolvedValue([
buildRecordedDay({ segments: [WALK_SEGMENT, second] }),
buildRecordedDay({ id: 4, date: '2026-05-15' }),
]);
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
await waitFor(() => expect(result.current.days).toHaveLength(2));
expect(repo.list).toHaveBeenCalledWith(7);
expect(result.current.byDate.get('2026-05-15')?.id).toBe(4);
expect(result.current.tracks).toHaveLength(3);
expect(result.current.tracks.every((track) => track.kind === 'day_recording')).toBe(true);
// One line per piece: a gap between two pieces is never drawn over.
expect(result.current.tracks.slice(0, 2).map((track) => track.points)).toEqual([
WALK_SEGMENT.points,
second.points,
]);
expect(result.current.tracks[0]?.color).toBe('#ff0055');
// A day without entries takes the colour of the live recording.
expect(result.current.tracks[2]?.color).toBe('#2563EB');
const ids = result.current.tracks.map((track) => track.place_id);
expect(ids.every((id) => id < -1000)).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
});
it('FE-JRN-RECORDINGS-HOOK-002: no journey yet, nothing asked', () => {
const { result } = renderHook(() => useJourneyRecordings(null, colorOf));
expect(repo.list).not.toHaveBeenCalled();
expect(result.current.tracks).toEqual([]);
expect(result.current.dialog).toBeNull();
});
it('FE-JRN-RECORDINGS-HOOK-003: a failed read keeps what the map already showed', async () => {
repo.list.mockResolvedValueOnce([buildRecordedDay()]);
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
await waitFor(() => expect(result.current.days).toHaveLength(1));
repo.list.mockRejectedValueOnce(new Error('offline'));
await act(async () => {
await pings.handlers.get('journey:recording:updated')?.();
});
expect(result.current.days).toHaveLength(1);
});
it('FE-JRN-RECORDINGS-HOOK-004: reads again when somebody on the journey keeps or drops a day', async () => {
repo.list.mockResolvedValueOnce([]);
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
await waitFor(() => expect(repo.list).toHaveBeenCalledTimes(1));
repo.list.mockResolvedValueOnce([buildRecordedDay()]);
await act(async () => {
await pings.handlers.get('journey:recording:updated')?.();
});
expect(result.current.byDate.has('2026-05-14')).toBe(true);
});
it('FE-JRN-RECORDINGS-HOOK-005: opens a day’s dialog with what it keeps, and shows a kept or dropped day at once', async () => {
repo.list.mockResolvedValue([]);
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
await waitFor(() => expect(repo.list).toHaveBeenCalled());
act(() => result.current.open('2026-05-14'));
expect(result.current.dialog).toMatchObject({
journeyId: 7,
journeyTitle: 'Venice',
date: '2026-05-14',
stored: undefined,
});
act(() => result.current.kept(buildRecordedDay()));
expect(result.current.dialog?.stored?.id).toBe(3);
expect(result.current.tracks).toHaveLength(1);
act(() => result.current.removed('2026-05-14'));
expect(result.current.days).toEqual([]);
act(() => result.current.close());
expect(result.current.dialog).toBeNull();
});
it('FE-JRN-RECORDINGS-HOOK-006: a first read that fails says so, rather than passing for a journey that kept no day', async () => {
repo.list.mockRejectedValueOnce(new Error('offline'));
const { result } = renderHook(() => useJourneyRecordings(JOURNEY, colorOf));
expect(result.current.status).toBe('loading');
await waitFor(() => expect(result.current.status).toBe('failed'));
expect(result.current.days).toEqual([]);
repo.list.mockResolvedValueOnce([buildRecordedDay()]);
await act(async () => {
await pings.handlers.get('journey:recording:updated')?.();
});
expect(result.current.status).toBe('ready');
});
});
@@ -0,0 +1,152 @@
import type { JourneyRecordedDay, JourneyTrack } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { journeyRecordingsRepo } from '../../repo/journeyRecordingsRepo';
import { useServerPing } from '../../sync/useServerPing';
import { DAWARICH_TRAIL_COLOR } from '../Map/dawarichTrail';
import type { JourneyRecordingViewProps } from './useJourneyRecordingDay';
interface JourneyRecordingsState {
days: JourneyRecordedDay[];
/** Whether the days were read: a failed first read is not a journey that kept none. */
status: 'idle' | 'loading' | 'ready' | 'failed';
byDate: ReadonlyMap<string, JourneyRecordedDay>;
/** The kept days as lines for the journey map, one per piece, never joined across a gap. */
tracks: JourneyTrack[];
/** The day whose recording dialog is open, or null. */
openDate: string | null;
/** What the desktop dialog or the phone sheet opens with while a day is open. */
dialog: JourneyRecordingViewProps | null;
open: (date: string) => void;
close: () => void;
/** A day was kept or replaced from the dialog: shown at once, without asking again. */
kept: (day: JourneyRecordedDay) => void;
removed: (date: string) => void;
}
/**
* The recorded days a journey keeps (JRN-01), for the desktop page and the
* phone screen alike: the list, the lines they draw on the journey map and
* which day's dialog is open. Read again whenever somebody on the journey
* keeps or drops a day. Offline the days stay as last read; a journey has no
* offline copy to fall back on.
*
* `colorOf` gives a day the colour its entries have on the map, so a kept
* route and the pins of its day read as one; a day without entries takes the
* colour of the live recording.
*/
export function useJourneyRecordings(
journey: { id: number; title: string } | null,
colorOf: (date: string) => string | null
): JourneyRecordingsState {
const journeyId = journey?.id ?? null;
const journeyTitle = journey?.title ?? '';
const { t, locale } = useTranslation();
// Held with the journey they belong to, so a journey opened after another
// never shows the other one's days, and nothing has to be reset in an effect.
const [loaded, setLoaded] = useState<{ journeyId: number | null; days: JourneyRecordedDay[] }>(NOTHING_LOADED);
const [opened, setOpened] = useState<{ journeyId: number | null; date: string | null }>(NOTHING_OPEN);
const [failedFor, setFailedFor] = useState<number | null>(null);
const days = loaded.journeyId === journeyId ? loaded.days : NO_DAYS;
const status =
journeyId === null
? 'idle'
: loaded.journeyId === journeyId
? 'ready'
: failedFor === journeyId
? 'failed'
: 'loading';
const openDate = opened.journeyId === journeyId ? opened.date : null;
const read = useCallback(async () => {
if (journeyId === null) return null;
try {
return { journeyId, days: await journeyRecordingsRepo.list(journeyId) };
} catch {
// What was read before stays on the map; a kept day does not vanish
// because one read of it failed.
return null;
}
}, [journeyId]);
useEffect(() => {
let cancelled = false;
void read().then((next) => {
if (cancelled) return;
if (next) setLoaded(next);
else setFailedFor(journeyId);
});
return () => {
cancelled = true;
};
}, [read, journeyId]);
const reload = useCallback(async () => {
const next = await read();
if (next) setLoaded(next);
}, [read]);
useServerPing('journey:recording:updated', journeyId !== null, reload);
const byDate = useMemo(() => new Map(days.map((day) => [day.date, day])), [days]);
const tracks = useMemo(
() =>
days.flatMap((day) => {
const name = t('journey.recording.mapLine', {
date: new Date(`${day.date}T00:00:00`).toLocaleDateString(locale, { day: 'numeric', month: 'long' }),
});
const color = colorOf(day.date) ?? DAWARICH_TRAIL_COLOR;
return day.segments.map((segment, index): JourneyTrack => ({
// Negative and far from the live trail's -1, -2, ... so the map keys never meet.
place_id: -(100_000 + day.id * 1_000 + index),
trip_id: 0,
name,
color,
points: segment.points,
kind: 'day_recording',
}));
}),
[days, colorOf, t, locale]
);
const kept = useCallback(
(day: JourneyRecordedDay) =>
setLoaded((prev) => ({
journeyId,
days: [...prev.days.filter((d) => d.date !== day.date), day].sort((a, b) => (a.date < b.date ? -1 : 1)),
})),
[journeyId]
);
const removed = useCallback(
(date: string) => setLoaded((prev) => ({ journeyId, days: prev.days.filter((d) => d.date !== date) })),
[journeyId]
);
const open = useCallback((date: string) => setOpened({ journeyId, date }), [journeyId]);
const close = useCallback(() => setOpened(NOTHING_OPEN), []);
const dialog = useMemo(
() =>
journeyId !== null && openDate
? {
journeyId,
journeyTitle,
date: openDate,
stored: byDate.get(openDate),
onKept: kept,
onRemoved: removed,
onClose: close,
}
: null,
[journeyId, journeyTitle, openDate, byDate, kept, removed, close]
);
return { days, status, byDate, tracks, openDate, dialog, open, close, kept, removed };
}
/** Stable empty identity, so a journey without kept days never re-renders its map for nothing. */
const NO_DAYS: JourneyRecordedDay[] = [];
const NOTHING_LOADED = { journeyId: null, days: NO_DAYS };
const NOTHING_OPEN = { journeyId: null, date: null };
@@ -1,4 +1,4 @@
// FE-JRN-SHAREHOOK-001 to FE-JRN-SHAREHOOK-008: the public share link logic behind // FE-JRN-SHAREHOOK-001 to FE-JRN-SHAREHOOK-009: the public share link logic behind
// both the desktop share section and the phone settings sheet. // both the desktop share section and the phone settings sheet.
import { act, renderHook, waitFor } from '@testing-library/react'; import { act, renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -123,4 +123,15 @@ describe('useJourneyShareLink', () => {
act(() => vi.advanceTimersByTime(2000)); act(() => vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false); expect(result.current.copied).toBe(false);
}); });
it('FE-JRN-SHAREHOOK-009: the plan and the recorded days are switched on their own and start off (JRN-04)', async () => {
const { result } = await loaded();
await act(() => result.current.togglePerm('share_plan'));
expect(posted).toEqual([{ share_timeline: true, share_gallery: false, share_map: true, share_plan: true }]);
expect(result.current.link?.share_plan).toBe(true);
expect(result.current.link?.share_recordings).toBeUndefined();
await act(() => result.current.togglePerm('share_recordings'));
expect(posted[1]).toEqual({ share_timeline: true, share_gallery: false, share_map: true, share_recordings: true });
});
}); });
@@ -10,9 +10,20 @@ export interface JourneyShareLink {
share_timeline: boolean; share_timeline: boolean;
share_gallery: boolean; share_gallery: boolean;
share_map: boolean; share_map: boolean;
/** JRN-04: the trips' plan and the recorded days on the shared map; off unless switched on. */
share_plan?: boolean;
share_recordings?: boolean;
} }
export type JourneySharePermission = 'share_timeline' | 'share_gallery' | 'share_map'; /** What the shared map can show besides the entries, each only while the map itself is shared. */
export type JourneyShareMapExtra = 'share_plan' | 'share_recordings';
export type JourneySharePermission = 'share_timeline' | 'share_gallery' | 'share_map' | JourneyShareMapExtra;
/** The map extras the share switches offer, with what each puts on the shared map. */
export const SHARE_MAP_EXTRAS: { key: JourneyShareMapExtra; labelKey: string; hintKey: string }[] = [
{ key: 'share_plan', labelKey: 'journey.share.plan', hintKey: 'journey.share.planHint' },
{ key: 'share_recordings', labelKey: 'journey.share.recordings', hintKey: 'journey.share.recordingsHint' },
];
/** The public address of a journey share token. */ /** The public address of a journey share token. */
export function journeyShareUrl(link: JourneyShareLink | null, origin: string): string { export function journeyShareUrl(link: JourneyShareLink | null, origin: string): string {
@@ -71,6 +82,8 @@ export function useJourneyShareLink(journeyId: number) {
share_timeline: updated.share_timeline, share_timeline: updated.share_timeline,
share_gallery: updated.share_gallery, share_gallery: updated.share_gallery,
share_map: updated.share_map, share_map: updated.share_map,
// The map extras go only when switched: a link read before they existed keeps them off.
...(key === 'share_plan' || key === 'share_recordings' ? { [key]: !!updated[key] } : {}),
}); });
} catch { } catch {
setLink(previous); setLink(previous);
@@ -1,4 +1,4 @@
// FE-COMP-BELL-001 to FE-COMP-BELL-020 // FE-COMP-BELL-001 to FE-COMP-BELL-021
import { render, screen, waitFor } from '../../../tests/helpers/render'; import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { vi } from 'vitest'; import { vi } from 'vitest';
@@ -238,4 +238,13 @@ describe('InAppNotificationBell', () => {
// fetchNotifications should be called once per open (2 total) // fetchNotifications should be called once per open (2 total)
expect(fetchNotifications).toHaveBeenCalledTimes(2); expect(fetchNotifications).toHaveBeenCalledTimes(2);
}); });
it('FE-COMP-BELL-021: unread badge is large enough that the count is not squeezed', () => {
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 150, isLoading: false });
render(<InAppNotificationBell />);
const badge = screen.getByTestId('notification-badge');
expect(badge).toHaveTextContent('99+');
expect(badge.style.height).toBe('20px');
expect(badge.style.minWidth).toBe('20px');
});
}); });
@@ -52,13 +52,14 @@ export default function InAppNotificationBell(): React.ReactElement {
<Bell className="w-4 h-4" /> <Bell className="w-4 h-4" />
{unreadCount > 0 && ( {unreadCount > 0 && (
<span <span
className="absolute -top-0.5 -end-0.5 flex items-center justify-center rounded-full text-white font-bold" data-testid="notification-badge"
className="absolute -top-1.5 -end-1.5 flex items-center justify-center rounded-full text-white font-bold tabular-nums ring-2 ring-surface-card"
style={{ style={{
background: '#ef4444', background: '#ef4444',
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))',
minWidth: 14, minWidth: 20,
height: 14, height: 20,
padding: '0 3px', padding: '0 5px',
lineHeight: 1, lineHeight: 1,
}} }}
> >
@@ -1,9 +1,10 @@
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-020 // FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-024
import React from 'react' import React from 'react'
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render' import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher' import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer' import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
describe('MapLayerSwitcher', () => { describe('MapLayerSwitcher', () => {
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => { it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
@@ -235,3 +236,79 @@ describe('TourMapLayerSwitcher via ferrata switch', () => {
expect(screen.getByRole('button', { name: 'Set points' })).toHaveClass('text-content-muted') expect(screen.getByRole('button', { name: 'Set points' })).toHaveClass('text-content-muted')
}) })
}) })
const pisteOverlay = (overrides: Partial<ToggleableRasterOverlay> = {}): ToggleableRasterOverlay => ({
id: 'pistes',
url: 'https://tiles.opensnowmap.org/pistes/{z}/{x}/{y}.png',
attribution: 'OpenSnowMap',
minZoom: 9,
maxZoom: 18,
active: false,
status: 'off',
belowMinZoom: false,
onToggle: vi.fn(),
onTileError: vi.fn(),
onTileLoad: vi.fn(),
...overrides,
})
describe('TourMapLayerSwitcher piste switch (SKI-01)', () => {
it('FE-COMP-MAPLAYER-021: the pistes switch sits on its icon alone, named by its tooltip, and offers no key while off', async () => {
const overlay = pisteOverlay()
render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer()} layers={{ overlays: [overlay] }} />)
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
expect(toggle).toHaveAttribute('aria-pressed', 'false')
expect(toggle.textContent).toBe('')
expect(screen.queryByTestId('piste-key-button')).toBeNull()
fireEvent.click(toggle)
expect(overlay.onToggle).toHaveBeenCalledTimes(1)
fireEvent.mouseEnter(toggle)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Pistes, cross-country trails and lifts')
})
it('FE-COMP-MAPLAYER-022: on, the switch is pressed in the accent and the key rides beside it in the same pill', () => {
render(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on' })] }} />)
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
expect(toggle).toHaveAttribute('aria-pressed', 'true')
expect(toggle).toHaveClass('bg-accent', 'text-accent-text')
const key = screen.getByTestId('piste-key-button')
expect(screen.getByRole('group')).toContainElement(key)
expect(key).toHaveAccessibleName('Piste key')
})
it('FE-COMP-MAPLAYER-023: a piste source that stopped answering shows the warning badge, says so, and hides the key', async () => {
render(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'down' })] }} />)
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
expect(toggle).toHaveAttribute('data-status', 'down')
expect(toggle).toHaveTextContent('!')
expect(screen.queryByTestId('piste-key-button')).toBeNull()
expect(screen.getByRole('status')).toHaveTextContent('Pistes are not loading. The map and your route still work.')
fireEvent.mouseEnter(toggle)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Pistes are not loading.')
})
it('FE-COMP-MAPLAYER-024: on further out than the pistes reach, the switch stays pressed in the soft accent and says to zoom in', async () => {
const { rerender } = render(
<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on', belowMinZoom: true })] }} />
)
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
expect(toggle).toHaveAttribute('aria-pressed', 'true')
expect(toggle).toHaveAttribute('data-below-min-zoom', 'true')
expect(toggle).toHaveClass('bg-accent-soft', 'text-accent')
expect(screen.getByRole('status')).toHaveTextContent('Zoom in to see the pistes')
fireEvent.mouseEnter(toggle)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Zoom in to see the pistes')
// The key stays at hand and opens with the same line on top.
fireEvent.click(screen.getByTestId('piste-key-button'))
expect(await screen.findByTestId('piste-key-zoom-in')).toHaveTextContent('Zoom in to see the pistes')
// A source that stopped answering says so instead, and close enough the switch is plainly on.
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'down', belowMinZoom: true })] }} />)
expect(screen.getByRole('status')).toHaveTextContent('Pistes are not loading.')
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on' })] }} />)
const plain = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
expect(plain).not.toHaveAttribute('data-below-min-zoom')
expect(plain).toHaveClass('bg-accent', 'text-accent-text')
expect(screen.getByRole('status')).toHaveTextContent('')
})
})

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