Skip to content

Tours: piste routing, Tours layers from TREK Places, tour times and fixes from testing - #2693

Merged
mauriceboe merged 45 commits into
devfrom
feat/tours-followup
Oct 20, 2026
Merged

mauriceboe merged 45 commits into
devfrom
feat/tours-followup

Conversation

@mauriceboe

@mauriceboe mauriceboe commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Follow-up to #2691: piste routing for ski and winter tours, the Tours layers read from TREK Places instead of the public Overpass mirrors, tour times in the day plan, Tours on by default, and a round of fixes from testing.

Piste routing for ski and winter tours (SKI-03)

  • What it routes:
    • Alpine and snowboard: over runs and lifts.
    • Cross-country: over trails, keeping classic or skating where the trail is tagged for it.
    • Ski touring, snowshoeing and winter hiking: over mapped routes, falling back to summer paths off the piste data.
  • Options: each discipline offers only what it reads: the hardest grade allowed, the kinds of lift, groomed runs first, and fastest, most skiing or easiest.
  • How it works:
    • TREK Places builds one routing graph per ski area from the planet every month: lifts joined to their stations, area runs with their ways in and out, direction from the terrain, and the directions each discipline may take. A quality check runs before each swap.
    • TREK downloads only the graph of the area it needs and searches it in a worker. Click positions never leave TREK.
  • When a leg cannot be routed: TREK says why and offers a fix in one click (for example "with red runs allowed it works"). The leg is drawn straight and marked unrouted, and a harder route is never drawn silently.
  • Saved tours and time: a saved ski tour opens as it was saved, without routing it again. Its time is the router's only when every leg was routed.
  • Admin and MCP: an admin switch, on by default, serves as an off switch. MCP gets plan_ski_route, and get_tour_plan names the options, build and routed legs.
  • No data yet: until the winter layer is live on TREK Places, every leg is drawn straight as before.

Tours layers from TREK Places instead of Overpass

  • What moved: SAC grades, surfaces, kinds of way and trail grades along a route now read the tags of the ways from TREK Places (/v1/ways). Horse routes read from /v1/routes.
  • Fallback: Overpass is asked only when a layer is not built yet, when the index is switched off, or when the answer fails. Each reason is logged once.
  • Circuit breakers: the layers have their own breaker, so a slow layer query never sends place search to its fallback.

Tour times

  • Imported tours (TIME-01): an imported hike gets the same DIN 33466 walking time the planner uses, from the track's distance, ascent and descent. A run gets distance times its pace.
    • It is marked as estimated everywhere, kept apart from a recorded or planned time, and worked out on reading, so older imports get it too.
    • A track without heights stays unknown. TREK never assumes it is flat.
  • Day plan (TIME-02): a tour on a day can have a start time.
    • Its end follows from the start plus its time and is marked as worked out. An end typed by hand wins.
    • A quiet line says when the tour runs into the next timed item of the day.
    • Works on desktop and phone.

Stages of a multi-day tour onto days

  • The choice: putting a multi-day tour on a day offers "Whole tour on this day" or "Stages from this day on". This works from every way in: the day menus, the detail footer, drag onto a day, and the phone.
  • Spreading: stage 1 goes on the chosen day and each next stage on the next trip day. A stage already on a day moves rather than doubling, and the whole tour comes off its day.
  • Fewer days than stages: the trip says so, and the stages past its last day stay without a day, visible in the stage list.
  • Undo and offline: one undo step takes the spread back. The writes go through the offline queue in order, and road trip vias stay on their legs.

Tours on by default

  • A fresh install has Tours enabled.
  • An install that already has the addon row is switched on once by a migration. An admin who turns it off afterwards keeps it off.

Collections

  • Kept tours are rows of a collection's list, like saved places, each with:
    • its grade or type tile, name, type and the trip it came from;
    • its measures and an "Add to a trip" button;
    • removing it, dimmed.
  • 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.

Fixes from testing

  • Share: sharing a tour's GPX failed on the second tap. The file is now fetched ahead, the share sheet opens inside the tap, and a cancelled share is no error.
  • 3D flyover: you can zoom in and out while it runs. The camera keeps following the route.
  • Dialogs: every Tours dialog opens in the middle of the screen.
  • Tour detail:
    • The times fill their row.
    • The trip days are boxes like the measures.
    • The footer buttons are even (Collection, Copy).
    • The name field keeps its distance to a save button as large as the close button.
  • Notifications: the unread count has room in its badge.
  • CI: the server test typecheck gets 8 GB of memory like the test jobs.

Migrations

  • 046140: Tours on for every install.
  • 046150: a ski tour keeps its router options, the piste data build and the legs the router planned.

Related Issue or Discussion

Tours board SKI-03, follow-up to #2691.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • I have read the Contributing Guidelines
  • My branch is up to date with dev
  • This PR targets the dev branch, not main (wiki-only PRs are exempt)
  • I have tested my changes locally
  • I have added/updated tests that prove my fix is effective or that my feature works
  • I have updated documentation if needed

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.
… 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.
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.
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.
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.
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.
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.
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.
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.
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.
…er 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.
…hout 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.
… was before

The table holds the seeded addons too, so compare against a snapshot taken before the migration with only tours switched on.
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.
…rtial 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.
…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.
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.
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.
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
… 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
…lanned

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
…es 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.
@github-actions

Copy link
Copy Markdown
Contributor

❌ Lint & Prettier check failed

Please fix the issues locally by running the following commands inside the shared package:

cd shared
npm run lint:fix
npx prettier --write <file>   # each file lint:format names
npm run lint:format -- --update   # when a listed file is formatted now

Then commit and push the changes.

…lanner 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.
…ki 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.
…pped 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.
@mauriceboe mauriceboe changed the title Tours: piste routing, places layers instead of Overpass, tour times and follow-up fixes Tours: piste routing, Tours layers from TREK Places, tour times and fixes from testing Oct 20, 2026
…ow-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.
…m 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.
…d, 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.
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.
… 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.
…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.
@sonarqubecloud

Copy link
Copy Markdown

@mauriceboe
mauriceboe marked this pull request as ready for review October 20, 2026 21:30
@mauriceboe
mauriceboe merged commit 9888908 into dev Oct 20, 2026
38 checks passed
@mauriceboe
mauriceboe deleted the feat/tours-followup branch October 20, 2026 21:30
@mauriceboe mauriceboe mentioned this pull request Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant