Commit Graph
173 Commits
Author SHA1 Message Date
randy 2c3dd4534e Add P4N_SESSION_COOKIE for p4n2gpx, since login alone can't work headless
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 13s
Traced the plugin's "Login failed" error directly against
park4night.com: correct credentials still get rejected because the
login form is behind invisible reCAPTCHA, which only a real browser
ever satisfies. p4n2gpx.rb (outside this repo) now checks
P4N_SESSION_COOKIE first and uses it as-is, skipping the login POST
entirely — the cookie comes from an already-authenticated browser,
copied out by hand and refreshed when it expires. Wired the env var
through docker-compose.yaml and the deploy workflow's "Write .env"
step the same way as P4N_USER/P4N_PASSWORD.
2026-09-09 18:07:03 +02:00
randy 92cc0c87ed Wire up p4n2gpx as a private plugin
CI / check (push) Successful in 47s
CI / deploy (push) Successful in 1m53s
data/plugins/p4n2gpx/ (outside git, see .gitignore) now holds a
plugin.json + run.rb wrapper around ../p4n2gpx/ruby/p4n2gpx.rb, so it
runs through the same --south/--west/--north/--east-in,
GeoJSON-on-stdout-out contract as the built-in overpass-pois plugin.
run.rb converts the requested bbox to the center+radius p4n2gpx.rb
itself expects, then reuses lib/maptool/gpx.rb to turn its .gpx output
into GeoJSON.

p4n2gpx.rb needs faraday-cookie_jar, which wasn't in the Gemfile yet
(faraday itself already was, transitively via oauth2) — added and
locked. Also wires P4N_USER/P4N_PASSWORD through docker-compose.yaml
and the deploy workflow's "Write .env" step, the same optional-secret
pattern as LIVETRACK_WEBHOOK_SECRET, since the container has no
browser for p4n2gpx.rb's Chrome-cookie auth fallback.
2026-09-09 17:38:53 +02:00
randy 6e0b67f2a0 Support a persistent, private plugins directory alongside the built-in ones
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 13s
plugins/ ships baked into the image and gets wiped/replaced by the
checkout sync on every deploy (docs/deployment.adoc), so a private
plugin dropped there — the exact use case the plugin contract was
designed for — didn't survive a redeploy the way uploaded GPX/kiwix
data under data/ does.

Plugins.list/run now accept either one directory or an array, and
app.rb checks the built-in plugins/ plus data/plugins/ (already
covered by the existing ./data bind mount, no new volume needed).
2026-09-09 17:24:52 +02:00
randy 1733fc7c1b Give Overpass POI downloads icons/descriptions and unique filenames
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 12s
Overpass categories now carry the same icon+color styling a p4n2gpx
waypoint gets, instead of falling back to gpx.js's plain red marker,
plus a popup description built from opening_hours/operator/website
tags. Supermarket/convenience store reuse NPS's generic store glyph,
the only honest match in that icon set.

Each plugin run's saved filename now bakes in the area's center and a
timestamp, so repeated downloads for different areas land as separate,
identifiable files instead of overpass-pois.geojson/-1/-2/...
2026-09-09 17:14:20 +02:00
randy 72ffd0ef18 Add an import plugin system, with the Overpass POI query as its first plugin
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 13s
A plugin is a directory under plugins/ with a plugin.json manifest
({"label": "...", "command": "./run.sh"}) plus a script that reads
--south/--west/--north/--east and prints a GeoJSON FeatureCollection
to stdout (lib/maptool/plugins.rb) — that's the entire interface a
plugin needs to know about this app. The sidebar's new import icon
(logged in only, GET /api/plugins + POST /api/plugins/:name/run) lists
whatever's found and runs one for the current map viewport, saving its
output as a new .geojson file the same way an upload would be.

The point: a scraper for a site whose ToS forbids that (Park4Night,
iOverlander, Freecampsites) doesn't need to live in this public repo
to work here — its own directory just goes under plugins/ locally and
is never git add'ed, no special-casing needed anywhere.

plugins/overpass-pois wraps the existing rake overpass:pois query as
the first, in-repo, reference example of the contract.

Also fills in four modules (auth.js, locate.js, measure.js, notes.js)
missing from layout.erb's cache-busting importmap alongside adding
plugins.js's own entry — a pre-existing gap that meant they could keep
serving a stale cached copy across a deploy despite the importmap
existing specifically to prevent that.
2026-09-09 16:23:31 +02:00
randy 2b49eda7cb Fix kiwix proxy 502ing on article links with non-ASCII titles
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s
Sinatra decodes a route's splat before handing it to the block, so an
article link like /kiwix/content/wikipedia/Dlhé_diely arrived as raw
UTF-8, not percent-encoded. URI#path= rejects that outright
(URI::InvalidComponentError) rather than escaping it, which .proxy's
rescue turned into a silent nil -> every article link 502'd, while the
search results page linking to them worked fine (its own path,
/kiwix/search, is plain ASCII).
2026-09-09 15:55:48 +02:00
randy 5ff9b113d9 Proxy kiwix-serve through this app instead of linking to it directly
CI / check (push) Successful in 39s
CI / deploy (push) Successful in 12s
map.priesol.net sits behind a reverse proxy that only forwards to
maptool itself, so the previous approach (publish kiwix-serve's port,
link the browser straight at it) had no address a real browser could
ever reach — not just a Docker-internal hostname as first assumed, but
no host/port combination at all works from outside.

GET /kiwix/* now proxies to kiwix-serve (Kiwix.proxy in
lib/maptool/kiwix.rb, a plain Net::HTTP passthrough), and the kiwix
service runs with --urlRootLocation=/kiwix so every link/asset it
emits already carries that same prefix and needs no rewriting
(confirmed against libkiwix/internalServer.cpp, which strips this
exact prefix off incoming requests). Drops the port-publishing
approach from the previous commit entirely.
2026-09-09 15:29:08 +02:00
randy 9997a8a8c0 Fix note UX: unreachable wiki link, white Save hover, stale icon preview, invisible new points
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 12s
- "Nearby on Wikipedia" linked to KIWIX_URL, a Docker-internal hostname
  (http://kiwix:8080) no real browser can resolve. Split it: KIWIX_URL
  stays server-side-only for the reachability check, and the link
  itself is built from KIWIX_PUBLIC_PORT (kiwix-serve's newly
  published host port) plus whatever host the visitor already used to
  reach maptool — works regardless of which hostname/IP that is.
- The Save button went white on hover in GTK-themed Chrome: the
  browser's native button skin was painting over the custom
  background because appearance wasn't reset.
- The map preview marker for a pending note always showed a plain red
  pin no matter which icon was picked in the form. NoteSaveForm now
  reports icon changes via onIconChange so NotesControl can update the
  marker's glyph live.
- Saving a note (or a Measure "Save as note") left the new/grown
  .geojson file unchecked and invisible until manually toggled on.
  notifyGpxStoreChanged now carries the affected path so
  GpxPanelControl auto-shows it.
2026-09-09 15:11:50 +02:00
randy 4f022ec43a Fix production outage: build context and kiwix arg duplication
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 11s
docker-compose's build context resolves relative to the symlink's own
directory (/srv/podman/maptool/), same as volume paths — but the
checkout now lives one level down in build/, so "build: ." pointed at
the wrong place and compose up --build failed outright looking for a
Dockerfile that isn't there. Point it at ./build instead.

Also drop kiwix's explicit --port=8080: the image's own start.sh
already prepends its default --port=8080 before appending the command
array, so passing it again produced "Unexpected argument" and the
container never started.

Both applied directly to production to restore service, verified via
docker-compose config + live health checks.
2026-09-09 14:29:40 +02:00
randy 76e3239d54 Tidy the deploy checkout into build/, symlink docker-compose.yaml up
CI / check (push) Successful in 44s
CI / deploy (push) Canceled after 40s
Sources now sync into /srv/podman/maptool/build/ (wiped and fully
replaced every deploy, nothing in it needs preserving) instead of
directly into /srv/podman/maptool/ itself, where data/ and .env also
live — the old sync step had to specifically skip data/ by name to
avoid wiping it; this way there's nothing to skip.

docker-compose.yaml is symlinked (ln -sf, relative target) from
/srv/podman/maptool/ up to build/ — both for a human to still find it
at the top, and because that symlink's own location, not build/, is
what compose resolves ./data and .env's directory against. No changes
to docker-compose.yaml or the podman-compose@.service systemd unit's
WorkingDirectory needed.

Verified against a full local dry run of the new sync step (copy into
a fake build/, symlink up, confirm pre-existing data/ survives) and
`docker-compose config` against the real docker-compose.yaml through
that symlink — both volume mounts (./data, the kiwix ZIM path)
resolved to the sibling data/ directory as intended, not build/data.
2026-09-09 14:13:42 +02:00
randy c66ae8d2d0 Fix kiwix ZIM volume mount to work with a symlink
CI / check (push) Successful in 1m7s
CI / deploy (push) Successful in 14s
Bind-mounting data/kiwix/ (the directory) meant a symlink placed
inside it broke: the container has to resolve that symlink itself,
in its own filesystem, where the host path it points to doesn't
exist. Binding the exact data/kiwix/wikipedia.zim file path instead
resolves it on the host at mount time (plain mount(2) semantics), so
the real ZIM can live anywhere on the host without duplicating a
multi-GB file into this repo's data/ directory.
2026-09-09 14:01:39 +02:00
randy 24ac5931f8 Link POI popups to offline Wikipedia via kiwix-serve
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s
lib/maptool/kiwix.rb: a small reachability check for an external
kiwix-serve instance. GET /api/kiwix reports it (same optional-
integration pattern as offline-tiles/terrain-tiles); the frontend
adds a "Nearby on Wikipedia" link to a point's popup pointing straight
at kiwix-serve's own geo search (GET /search?latitude=&longitude=
&distance=, confirmed from libkiwix's actual source rather than
guessed) — linked to directly, not proxied/embedded, since it already
has a full search UI of its own.

docker-compose.yaml: new `kiwix` service (ghcr.io/kiwix/kiwix-serve)
on the default network, KIWIX_URL wired to reach it by service name.
The ZIM itself isn't part of the repo (large, self-contained archive)
— data/kiwix/ gitignored same as the *.pmtiles files, setup steps in
docs/development.adoc.

No kiwix-serve/ZIM available here to test end-to-end — verified the
app side against a stand-in local HTTP server (GET /api/kiwix flips
available correctly) and confirmed the generated popup link matches
the real geo-search query shape. docker-compose.yaml validated with
`docker-compose config`. Full test suite: 118 runs, 0 failures.
2026-09-09 13:44:37 +02:00
randy 7cd3171998 Mark Park4Night-to-pictogram mapping as done, verified against real exports 2026-09-09 13:16:34 +02:00
randy 5576d2edf1 Note UX polish: pin marker, icon picker, fix Save button color
- The clicked location now stays marked (a plain maplibre-gl Marker,
  removed on save/cancel/deactivate) while the note form is open —
  before, nothing on the map showed where the pending note would land.
- POI_ICONS gains a dozen more symbols (water, dump station, toilets,
  playground, viewpoint, swimming, fishing, climbing, hiking, …) via
  Object.assign after PARK4NIGHT_TAGS' own icon constants are declared
  (they're below POI_ICONS in the file, so the literal itself can't
  reference them directly) — reused as real marker-badge glyphs, not
  just PARK4NIGHT_TAGS' inline description-tag icons. NoteSaveForm
  gets a picker grid for these, shown only for a Point note (a
  Measure-drawn line/polygon has no one spot for a badge).
- Fixed: .notes-panel__save's blue background was losing to
  .notes-panel__actions button's gray — same class-vs-class+element
  specificity trap .gpx-panel__action[hidden] already has a comment
  about elsewhere in this file. The button was never actually blue.

Verified live (properly simulating a logged-in session this time, via
Page.addScriptToEvaluateOnNewDocument rather than a post-load DOM
poke, since NotesControl's mount decision happens once, synchronously,
at page load): marker appears and clears correctly, 19 icons + "none"
render and select, Save renders #1a5fb4. Full suite: 115 runs, 0
failures.
2026-09-09 13:14:19 +02:00
randy 465a644365 Split notes into a title + body, with light self-authored markdown
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 13s
Both NoteSaveForm (create) and the popup's inline Edit form now have
a title input (-> properties.name, shown in the black header, same as
every other waypoint popup) and a separate body textarea (->
properties.description) for more detail than a single line.

New formatNoteBody renders that body with a small self-rolled
markdown subset — **bold**, *italic*, auto-linked URLs (reusing the
existing linkify), newlines to <br> — deliberately a new function, not
a change to formatDescription: that one renders uploaded GPX/park4night
content, which is untrusted, and docs/nps-resources.adoc already has a
specific call not to add markdown rendering to that path. A note's
body is written by the logged-in viewer themselves, not uploaded, so
that concern doesn't apply here — the escape-then-selectively-markup
order (linkify first, since it does its own escaping, then wrap
already-escaped text in real <strong>/<em> tags) keeps it safe either
way.

Verified live: title renders in the popup header, **bold**/*italic*/
a URL in the body all render correctly. Full test suite: 115 runs, 0
failures (unaffected — description is just another property, not
something the backend routes inspect).
2026-09-09 13:03:42 +02:00
randy 2a22906a68 Fix Edit/Delete on notes saved before properties.id existed
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s
The earliest notes (saved before the previous commit added id
tracking) have no properties.id — update/delete target a feature by
that id, so offering Edit/Delete for one without it just 404s
("Could not delete/save the note", the generic fetch-failure alert).

The popup no longer offers Edit/Delete without an id. New
rake gpx:backfill_note_ids assigns one to any pre-existing Note
feature missing it, so already-saved notes become editable again too
— run once against wherever data/gpx actually lives (e.g. inside the
deployed container).
2026-09-09 12:58:19 +02:00
randy b0576f3725 Make Measure's vertices draggable and deletable
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s
Drag: mousedown on a vertex circle calls event.preventDefault() (the
documented way to opt a gesture out of MapLibre's own drag-to-pan),
then a map-wide mousemove/mouseup follows the cursor and updates that
point's coordinates live. Delete: clicking a vertex selects it (shown
larger/yellow); Delete or Backspace removes it, guarded against
eating a keystroke meant for an actual text input on the page.

A vertex's own mousedown sets a one-shot flag so the same click
doesn't also fall through to the generic map-click handler and add a
brand new point on top of it.
2026-09-09 12:47:44 +02:00
randy b5b91d0df5 Gate note-writing UI behind login
Adding a note (either tool) always ended in a 401 for a logged-out
viewer — the write-only affordance just wasn't hidden yet, unlike
GpxPanelControl's own upload/new-folder buttons, which already are.

NotesControl isn't added to the map at all when logged out (its whole
purpose is writing); MeasureControl's "Save as note" button is hidden
but the tool itself stays usable for plain measuring either way.
2026-09-09 12:44:52 +02:00
randy aafb4cb4a0 Consolidate note-drawing into Measure; add edit/delete
CI / check (push) Successful in 39s
CI / deploy (push) Successful in 13s
Measure already draws distance lines and area polygons by hand — a
second click-to-draw UI in the notes tool (Point/Area mode, from the
previous commit) duplicated that. Replaced it with a "Save as note"
button on the Measure panel that saves whatever shape is currently
drawn there; NotesControl goes back to being just a one-click point
pin. Both share a single NoteSaveForm (notes.js) for the note-text +
target-layer-file UI instead of two copies of it.

gpx.js: the "lines" source now also renders a note-flagged LineString
(dashed, popup-on-click) alongside the Polygon fill/outline from
before — the plain track-line layer excludes category:"Note" features
so a saved distance note doesn't also get the track hover-summary/
profile-panel treatment.

Notes are now individually editable/deletable from their own popup
(logged-in only): Edit swaps the popup content for an inline
textarea, Delete confirms and removes. Both target the feature by a
new client-generated properties.id rather than array position (new
GpxStore#update_feature/#delete_feature, PUT/DELETE /api/gpx/notes),
since neither array index nor the note's own text (the thing being
edited) is a stable identifier.

Verified live: Measure's Save button (disabled/enabled state, saves
the exact on-screen shape), the popup's Edit/Delete buttons and inline
edit form. Full test suite: 115 runs, 0 failures (new coverage for
update_feature/delete_feature and the PUT/DELETE routes).
2026-09-09 11:39:36 +02:00
randy db58468187 Add polygon notes (Area mode) to the user overlay tool
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s
notes.js: Point/Area mode toggle mirroring measure.js's UX — Area
mode accumulates clicked vertices with a live dashed-preview polygon
(own small preview source/layers), a Finish button once 3+ corners
are placed, then the same save form as Point mode.

gpx.js's _addLayers now also renders Polygon features from a file's
"lines" source (fill + outline, filtered by geometry-type — the
existing line layer already only draws LineString, so this is just
two more layers on the same source, not a new bucket) with
popup-on-click like a waypoint, not the track line's hover-summary/
profile-panel behavior. Without this a saved polygon wouldn't have
rendered back on reload.

Fixed along the way: .notes-panel__form/.notes-panel__modes both set
their own `display`, which silently wins over the browser's default
`[hidden] { display: none }` (author styles always beat user-agent
ones) — same gotcha .gpx-panel__action[hidden] already has a comment
about. The form was never actually invisible before being opened.

Verified live: real polygon file (fill/outline render, click ->
popup) and the full Area-mode draw flow (mode switch, live preview,
Finish, form) via headless Chrome. Full test suite: 104 runs, 0
failures — one new backend test confirms a Polygon feature round-
trips through POST /api/gpx/notes.

Known gap (noted in TODO.adoc): the category filter matrix only
re-slices the points source, so toggling "Note" off hides point notes
but not polygon ones.
2026-09-09 11:27:25 +02:00
randy f53c74e06f Add rake overpass:pois to fetch camper POIs from Overpass
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s
lib/maptool/overpass_pois.rb: queries Overpass for drinking water,
dump stations, LPG refill, toilets, laundromats, supermarkets/
convenience stores, and springs within a bbox, writes GeoJSON. A
dev-time tool (like the offline.pmtiles/terrain.pmtiles generation
steps already documented) — not called by the running app, drop the
output into data/gpx/ or upload it through the Tracks panel like any
other .geojson layer.

Verified against the real Overpass API (88 POIs for central
Bratislava) in addition to the new unit tests (pure to_geojson/
build_query logic, no network in the suite itself). Full suite: 103
runs, 0 failures.
2026-09-09 10:21:41 +02:00
randy aa2b96ac70 Add a "user overlay" tool: click the map, save a note
New map-corner "Add note" control (public/js/notes.js), mirroring
measure.js's click-to-place UX. Click the map, write a short note,
pick an existing .geojson layer file or name a new one — saved via a
new GpxStore#append_feature (grows the target file in place, unlike
save()'s always-disambiguate-the-filename behavior) through a new
POST /api/gpx/notes route.

No new rendering path: a note is just another point in a .geojson
file the Tracks panel's tree already lists, checkboxes, and pops up —
visibility toggling ("layers") comes for free from that existing
machinery, per-file, with no changes needed there.

Points only for now — a polygon mode would need gpx.js's point/line
split taught a third Polygon bucket first, or a saved polygon
wouldn't render back on reload (see TODO.adoc).

Covered by new tests: GpxStore#append_feature (create/append/missing-
path/filename-safety) and the /api/gpx/notes route (auth gate, create,
append, malformed/missing-geometry rejection, 404 on a missing
target). Full suite: 97 runs, 0 failures.
2026-09-09 10:18:50 +02:00
randy 5caf865fc8 Cache radar tile images with a Service Worker
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s
public/sw.js: network-falling-back-to-cache for RainViewer radar tile
requests specifically (nothing else — the app itself is served by the
same local server that's already the offline resource in the van, so
there's no app-shell to precache). Registered from layout.erb, a no-op
where it can't register (insecure context) rather than an error.

Verified: registers and controls the page, tiles get cached on a
normal fetch, cache.match() correctly retrieves them by the same
request. CDP's network-blocking simulation doesn't interact cleanly
with Service Worker fetch interception (a known tooling quirk, not
specific to this), so the actual offline fallback path is the
standard try/catch pattern, verified by code review rather than a
simulated-offline screenshot.
2026-09-09 09:51:29 +02:00
randy a98b8dbd47 Mark GPX drag-and-drop import as done in TODO 2026-09-09 09:47:14 +02:00
randy 8d090b0774 Cache the weather forecast for offline fallback
Radar flyout now also shows a 3-day Open-Meteo forecast (free, no API
key) for the map's current center. Radar frames and the forecast fetch
independently — one being unreachable doesn't hide the other. The last
successful forecast is cached in localStorage and shown (with a
"cached from" note) when the live fetch fails, so there's still
something useful once actually offline.

Radar tile images themselves aren't cached — that needs a Service
Worker, left as a separate follow-up (see TODO.adoc).
2026-09-09 09:42:10 +02:00
randy 707e71caf1 Add elevation-tinted terrain (hypsometric color-relief layer)
Reuses the same raster-dem source the hillshade toggle already added
— a color-relief layer tints by elevation band (green valleys through
brown mid-slopes to pale grey peaks, in this app's own palette) under
the hillshade layer, so one toggle gives the combined shaded-relief
look instead of two separate layers.
2026-09-09 09:33:00 +02:00
randy 77a4c96b1c Add a hillshade/terrain-shading toggle to the map
Sidebar toggle (new HillshadeControl) adds a raster-dem hillshade
layer: public Terrarium tiles when online, or a local
data/terrain.pmtiles when present (mirrors offline.pmtiles' optional-
local-file pattern, new /api/terrain-tiles + /tiles/terrain.pmtiles
endpoints). No offline terrain data is bundled, so the toggle is
online-only until one is generated — see docs/development.adoc for
how.
2026-09-09 09:27:17 +02:00
randy f49e44800a Distinguish unpaved tracks from paved roads on both basemaps
Offline: fill in protomaps' "other" road-kind color (unset before,
so tracks/paths silently fell back to plain white against the cream
background); also fixes a dead "minor" theme key that was never read
by the actual paint expression (only minor_a/minor_b is), so ordinary
streets were quietly using the base theme's default yellow instead of
this app's cream.

Online: Liberty's own road_service_track layer merges paved service
roads with unpaved tracks into one color, which isn't safe to just
recolor brown (real driveways aren't unpaved). Clones it into a
second layer scoped to class=="track" only, dashed and dirt-brown,
matching the offline treatment without mislabeling paved service
roads.
2026-09-09 09:17:00 +02:00
randy 8ca5ca9bc1 Add an offline-data status dot to the topbar
Small green/gray indicator reusing the existing /api/offline-tiles
check, showing at a glance whether this server has offline map data.
Closes out the top banner TODO item — DEM elevation and a hamburger
menu are intentionally left out (no DEM layer yet, and the sidebar
isn't mobile-collapsed).
2026-09-09 09:11:52 +02:00
randy 53b0e8961b Turn the GPX category legend into a clickable Unigrid filter matrix
CI / check (push) Successful in 2m5s
CI / deploy (push) Successful in 13s
Legend items are now toggle buttons (icon grid, not a color-swatch
list) that hide/show that category's waypoints on the map. Filtering
re-slices each file's source data rather than using a layer filter,
since MapLibre clusters a GeoJSON source's full data regardless of
what a layer filter hides.
2026-09-09 09:09:07 +02:00
randy bc9ea58614 Start the NPS Unigrid visual redesign
Black topbar banner with live map-center coordinates, a black header
(with NPS pictogram) on waypoint popups, offline basemap wood/park
colors unified with the online Liberty green, and the sidebar grouped
into the planned Explorer / Offline hub / Navigation menu sections.
2026-09-09 09:02:29 +02:00
randy d1f2e7376e Add offline vanlife feature ideas to TODO
Captures ideas for offline maps/POI data, an NPS Unigrid-inspired
visual redesign, a 4-module menu reorg, and custom layer support.
Hardware-dependent features (leveling, Victron/HA telemetry) are
parked under Nice to have for later.
2026-09-09 08:18:07 +02:00
randy 5c9516d038 Check the deployed revision, not just any 200, in the health poll
CI / check (push) Successful in 53s
CI / deploy (push) Successful in 13s
/api/health now reports REVISION alongside status, and the CI wait
step checks for this deploy's own short SHA specifically - a stray
old container that never got torn down (or a build that silently
kept serving stale code) would still 200 on a plain health check but
now won't match.
2026-09-08 21:19:03 +02:00
randy 4982f04258 Verify the deploy actually came up, not just that it was launched
systemctl restart (Type=simple, no readiness protocol) returns as
soon as podman compose up --build forks, before the build or the
container start have actually finished - a build failure or crash
loop would still show as a green deploy job. Poll /api/health for up
to 5 minutes (comfortably above the ~3.5min worst-case cold rebuild)
and fail the job if it never answers.
2026-09-08 21:16:41 +02:00
randy a908cd7abc Fix the real cause of slow podman deploys: add --build, drop podman rmi
CI / check (push) Successful in 44s
CI / deploy (push) Successful in 3s
podman-compose@.service was missing the --build flag that
docker-compose@.service already has (added on cube.local:
/etc/systemd/system/podman-compose@.service), so removing
maptool:latest before every restart (my own earlier fix, meant to
force a rebuild) also destroyed the build cache along with it - every
deploy was redoing apt-get/bundle install from scratch (~3 min
measured, vs ~20s before). --build lets a plain systemctl restart
rebuild while still reusing unchanged layers, same as docker did, so
the ci.yml deploy step collapses back to one line.
2026-09-08 21:12:54 +02:00
randy 049d54e44e Use the short maptool:latest image name in the deploy job
CI / check (push) Successful in 43s
CI / deploy (push) Successful in 4s
docker.io/library/maptool:latest and maptool:latest resolve to the
same local image (verified on cube.local) - the docker.io/library
prefix is just podman's normalization of the unqualified name, not an
actual registry reference. Short form matches what docker-compose.yaml
itself uses and avoids implying a Docker Hub push/pull that never
happens.
2026-09-08 20:38:14 +02:00
randy e4764c64e4 Drive maptool's deploy through the podman-compose@ systemd service
The service's own ExecStart deliberately runs a plain podman compose
up (no --build) since ExecStartPre already tears down the previous
containers/networks/volumes on every start — but that leaves the
built image alone, so the deploy job now removes it directly first
(what forces a fresh build) and drives start/stop through systemctl
rather than calling podman/docker compose by hand. Also switches
/srv/docker/maptool to /srv/podman/maptool per the symlink now in
place, and updates deployment.adoc to match.
2026-09-08 20:33:03 +02:00
randy 0c4ee97368 Flag LiveTrack sessions whose map Garmin no longer serves
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 20s
Ended sessions past the ~24h Garmin appears to keep a live-track page
around for get verified once at app startup and, once confirmed dead,
flagged so the panel greys them out instead of only failing after a
click. Already-flagged sessions are skipped on later startups so this
doesn't keep re-hitting Garmin for the same dead session.
2026-09-08 14:19:15 +02:00
randy 141b7a08ce Show accuracy circle around the browser-location fallback marker
Re-adds the gray radius MapLibre's own GeolocateControl used to draw,
using the same element/class and pixel-radius calculation.
2026-09-08 14:16:16 +02:00
randy 67585cd99a Gate GNSS (turning it on and its position) behind login
CI / check (push) Successful in 48s
CI / deploy (push) Successful in 23s
GET /api/gnss/position now requires login (app.rb) -- it reveals
where the receiver physically is, not something to leave open to
anyone with the URL. GnssControl's sidebar icon is hidden entirely
when logged out (same pattern as the tree UI's write controls) rather
than shown and failing to ever get a fix.

LocateControl needs no changes: it only ever reaches GNSS through
GnssControl#isEnabled(), which can't turn true for a logged-out
visitor if the toggle is never on the page to click. Its
browser-geolocation fallback stays open to everyone -- a visitor's
own device asking for their own location isn't something to gate.

Extracted isLoggedIn() out of gpx.js into a shared public/js/auth.js
(gnss.js needed the same check) rather than duplicating it.

Verified: /api/gnss/position returns 401 logged out; the sidebar icon
is actually hidden (computed display: none, not just present-but-
unstyled -- checked properly this time after almost repeating the
same present-vs-visible test mistake the .gpx-panel__action bug
already taught).
2026-09-08 14:06:29 +02:00
randy d9879bad60 Switch to GNSS immediately if it turns on mid-track
CI / check (push) Successful in 47s
CI / deploy (push) Successful in 18s
If Locate was already tracking via the browser-geolocation fallback
(GNSS was off when clicked) and GNSS then gets turned on via the
sidebar, it kept using the browser position until the button was
clicked again -- the browser's own (often less accurate) position had
no business still being used once the app's own receiver had a fix.

GnssControl#onEnabledChange (a small internal callback list) notifies
LocateControl the moment the sidebar toggle changes; if it's actively
tracking via the browser at that point, it stops that and jumps to
GNSS's position instead.

Caught a real bug while verifying this: the automatic handoff reused
the same jump-to-GNSS helper a deliberate click uses, which alerts if
GNSS has no fix yet -- but GNSS only just turned on and almost
certainly hasn't gotten its first poll response back yet at the exact
moment the handoff fires, so this popped a blocking window.alert()
(which halts the whole page until dismissed) on every single enable.
A test hung on exactly this before I noticed. Fixed with an
alertIfNoFix option, true only for the deliberate-click path.

Verified end to end (simulating the "already tracking" state directly
since headless geolocation isn't reliable enough to trust as the
trigger): toggling GNSS mid-track removes the blue browser marker,
shows the green GNSS one once its poll lands, and no dialog appears.
2026-09-08 08:50:43 +02:00
randy f0ac8ac5d4 Give the browser-location fallback its own (blue) marker color
CI / check (push) Successful in 50s
CI / deploy (push) Successful in 25s
The browser-geolocation fallback was reusing GnssControl's green
.gnss-marker for its own position dot -- on a machine with no real GPS
chip, wifi/IP-estimated location can jump around noticeably between
watchPosition updates, and a green dot doing that reads as "GNSS is
somehow still being used" even when it very much isn't (confirmed:
code review found no path from locate.js to /api/gnss/position at
all, and GNSS was off in the report). Switched to MapLibre's own blue
pulsing .maplibregl-user-location-dot -- the same look GeolocateControl
itself had here before, and now visually distinct from GNSS's green
on purpose rather than by accident.

Verified: both markers rendered side by side (GNSS on via the
simulator, plus a manually-placed browser-fallback-style marker) --
clearly different colors, no shared styling.
2026-09-08 08:04:31 +02:00
randy 6e5676c890 Make "find my location" actually follow GNSS, gated on the sidebar toggle
CI / check (push) Successful in 44s
CI / deploy (push) Successful in 18s
Two fixes based on feedback after the last commit:

1. The map wasn't following the position at all -- only the first fix
   recentered, later updates just moved the marker. Now every fix
   recenters (easeTo, not flyTo, after the first one, so it doesn't
   repeat that zoom swoop on every update) -- matches how
   GeolocateControl's own trackUserLocation always behaved.

2. GNSS should only be used when explicitly turned on via the sidebar
   "GNSS" icon, not whenever a receiver happens to have a fix --
   simpler to test against the simulator that way, and puts the
   visitor in control of whether GNSS is engaged at all. GnssControl
   now owns following the map on its own (same flyTo-once/easeTo-after
   pattern) whenever it's enabled; LocateControl checks
   GnssControl#isEnabled() and, when true, just jumps to
   #getPosition()'s current fix and reuses its existing .gnss-marker
   instead of polling/marking on its own. Only falls back to the
   browser's own geolocation when GNSS is off.

Verified end to end with the GNSS simulator: enabling sidebar GNSS
recenters immediately and keeps following over several polls;
clicking Locate while GNSS is on jumps to the same position without
creating a second marker or touching its own active state; turning
GNSS back off and clicking Locate correctly falls through to the
browser geolocation path instead.
2026-09-08 00:56:25 +02:00
randy aec665179f Prefer this app's own GNSS receiver for "find my location"
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 22s
LocateControl (public/js/locate.js) replaces MapLibre's own
GeolocateControl -- same button, reusing its exact CSS/icon so it's a
visual drop-in, but sourced differently. Checks GET /api/gnss/position
once on click: if a fix is available (gpsd, possibly
bin/gnss_simulator.rb's simulated one), tracks that instead of the
browser's separate (and often less accurate) location service, which
is now only a fallback. Only the first fix recenters the map; later
updates just move the marker, unlike GeolocateControl's own default
of recentering on every update, which would fight a pan/zoom made in
the meantime.

Verified end to end with the GNSS simulator running: clicking the
button centered on the simulated position and dropped a marker,
clicking again cleared both, no console errors.
2026-09-08 00:46:14 +02:00
randy 64a85afe6a Fix a filter-syntax warning found while investigating missing labels
CI / check (push) Successful in 41s
CI / deploy (push) Successful in 19s
Mixing the legacy "$type" filter with an expression (["get", "role"])
in the same filter logs as unsupported in newer MapLibre. Modernized
all three measure layer filters to expression syntax; the vertex
circle layer's filter simplifies to just checking "role" (it's only
ever set on Point features to begin with, so the geometry-type check
was redundant once "role" was added).

Found while trying to reproduce a report that per-segment distance
labels don't show on a longer, several-point measurement -- couldn't
reproduce it locally or against production itself (both showed labels
correctly on a fresh page load), so this may be unrelated; still
worth fixing on its own.
2026-09-08 00:39:02 +02:00
randy 3e81ee8015 Run the GNSS simulator in production too
Adds a gnss-simulator service to docker-compose.yaml, sharing
maptool's network namespace (network_mode: "service:maptool") so it's
reachable at the 127.0.0.1:2947 GpsdClient hardcodes -- there's no
real receiver attached to the server, so without this the "GNSS"
control there always shows "no fix". Both services now reference the
same image (maptool:latest) explicitly rather than each service with
its own `build: .` producing two separately-tagged (if
identically-layered) images.

Verified for real: built and ran the full compose stack in an
isolated project/port on cube (not the live one), confirmed
/api/gnss/position reports a moving fix across two polls through the
actual sidecar networking, then tore it down completely -- containers,
image, network, temp checkout. Production's own stack was untouched
throughout and confirmed still healthy afterward.
2026-09-08 00:33:44 +02:00
randy 61ba86c6e6 Add a GNSS simulator for dev without gpsd running
bin/gnss_simulator.rb speaks gpsd's own JSON wire protocol directly
on its default port -- GpsdClient connects to it exactly as it would
to real gpsd, no app changes needed, and no gpsd install/daemon
required either (unlike the existing fake-NMEA-source approach in
docs/development.adoc, which still needs gpsd itself in front of it).
Drives a loop around a center point at a driving-ish pace rather than
sitting still, since a fixed position isn't much of a "GNSS position"
to look at.

Verified end to end: started it, pointed a dev server at it, and
confirmed /api/gnss/position reports a moving fix across two polls.
2026-09-08 00:28:34 +02:00
randy 4aafe332a7 Fix upload/new-folder buttons not actually hiding when logged out
CI / check (push) Successful in 47s
CI / deploy (push) Successful in 25s
.gpx-panel__action's own "display: flex" outranks the browser's
default "[hidden] { display: none }" -- same specificity, but an
author stylesheet rule always beats a user-agent one -- so setting
.hidden = true on these (GpxPanelControl#mount) never had any visual
effect. The per-file row icons (delete/download) were unaffected by
this since they're conditionally never added to the DOM at all rather
than hidden after the fact, a different mechanism that doesn't hit
this bug.

Same class of bug as the earlier Distance/Area overlap fix. Swept the
rest of the app's [hidden]-manipulated elements for the same pattern
(a display override with no [hidden] counterpart) -- this was the
only other one.

Verified in a real browser: computed display is now "none", not just
the hidden attribute being set with no visual effect.
2026-09-08 00:16:10 +02:00
randy ca224e95c1 Show the tree's file names without the .geojson suffix
The suffix ("ride.gpx" -> "ride.gpx.geojson" once cached) explained
what the name would download back when it doubled as a GeoJSON-only
download link. Now that downloading is the download button's job
(previous commit), it was just confusing -- the file itself is still
named "ride.gpx".
2026-09-08 00:11:41 +02:00
randy 1b4463a3c1 Consolidate original/GeoJSON download into one menu button
The file name doubling as a GeoJSON-only download link was easy to
miss as a link at all, and only ever covered half of what's actually
downloadable. One button next to delete now opens a small menu
(Original file / GeoJSON) instead -- costs a click when downloading
several files in a row, but both formats are reachable from one,
discoverable place. Only one menu open at a time, closed on any
outside click.

Verified in a real browser: opens with both items, closes on outside
click, no console errors.
2026-09-08 00:10:02 +02:00