Compare commits

..
2 Commits
Author SHA1 Message Date
randy 0c4ee97368 Flag LiveTrack sessions whose map Garmin no longer serves
CI / deploy (push) Successful in 20s
CI / check (push) Successful in 42s
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
6 changed files with 136 additions and 5 deletions

No files matched your search

+19
View File
@@ -325,6 +325,25 @@ module Maptool
json sessions: LIVETRACK_STORE.list json sessions: LIVETRACK_STORE.list
end end
# Garmin stops serving a session's live-track page some while after the
# activity ends (its own session/token URL, not the API — see
# LiveTrackClient) — the exact cutoff isn't documented, ~24h is what's
# been observed. Ended sessions old enough to likely be past that are
# verified once at startup (LiveTrackStore#stale_ended already skips
# ones already flagged, so this doesn't re-hit Garmin for the same
# dead session on every restart), so the panel can grey them out up
# front instead of only failing after a user clicks one and waits on
# a fetch.
LIVETRACK_MAP_STALE_AFTER = 24 * 60 * 60 # seconds
Thread.new do
LIVETRACK_STORE.stale_ended(LIVETRACK_MAP_STALE_AFTER).each do |session|
LiveTrackClient.fetch(session["id"], session["token"])
rescue LiveTrackClient::FetchError
LIVETRACK_STORE.mark_unavailable(session["id"])
end
end
# Garmin's own API (livetrack.garmin.com/api/v2/...) 403s a plain server # Garmin's own API (livetrack.garmin.com/api/v2/...) 403s a plain server
# request no matter what headers it gets — see LiveTrackClient — so this # request no matter what headers it gets — see LiveTrackClient — so this
# fetches the session page itself and pulls the track out of that. # fetches the session page itself and pulls the track out of that.
+39
View File
@@ -55,6 +55,31 @@ module Maptool
end end
end end
# Ended sessions (summary["active"] == false) that ended more than
# `stale_after` seconds ago and aren't already flagged unavailable —
# i.e. ones Garmin has very likely stopped serving the live-track page
# for, but that haven't been confirmed yet (see app.rb's startup
# check). Excludes already-flagged ones so that check doesn't re-hit
# Garmin, on every restart, for a session it already told us is gone.
def stale_ended(stale_after)
cutoff = Time.now.utc - stale_after
list.select { |s| stale_ended?(s, cutoff) }
end
# Flags a session's live-track page as no longer served by Garmin, once
# confirmed (see app.rb's startup check) — the summary itself (small,
# and a record of the activity, see the class comment) is left alone.
def mark_unavailable(session_id)
@mutex.synchronize do
sessions = read
session = sessions.find { |s| s["id"] == session_id }
return unless session
session["map_available"] = false
write(sessions)
end
end
# Most recently received first. # Most recently received first.
def list def list
prune(read) prune(read)
@@ -77,6 +102,20 @@ module Maptool
Time.now.utc # malformed/missing timestamp: don't let it block pruning Time.now.utc # malformed/missing timestamp: don't let it block pruning
end end
def stale_ended?(session, cutoff)
summary = session["summary"]
return false unless summary && summary["active"] == false && session["map_available"] != false
ended = ended_at(summary)
ended && ended < cutoff
end
def ended_at(summary)
Time.parse(summary["end"])
rescue ArgumentError, TypeError
nil # malformed/missing "end": can't tell how long it's been, so don't guess
end
def write(sessions) def write(sessions)
File.write(@path, JSON.pretty_generate(sessions)) File.write(@path, JSON.pretty_generate(sessions))
end end
+5
View File
@@ -781,6 +781,11 @@ html, body {
cursor: wait; cursor: wait;
} }
.livetrack-panel__row:disabled {
opacity: 0.5;
cursor: default;
}
.livetrack-panel__label { .livetrack-panel__label {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+6 -3
View File
@@ -76,15 +76,18 @@ export class LiveTrackPanelControl {
return; return;
} }
sessions.forEach(({ id, received_at, summary }) => { sessions.forEach(({ id, received_at, summary, map_available }) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "livetrack-panel__item"; li.className = "livetrack-panel__item";
const unavailable = map_available === false;
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "livetrack-panel__row"; button.className = "livetrack-panel__row";
button.classList.toggle("active", id === this._activeSessionId); button.classList.toggle("active", id === this._activeSessionId);
button.title = id; button.disabled = unavailable;
button.title = unavailable ? "Garmin no longer serves this session's map" : id;
const labelSpan = document.createElement("span"); const labelSpan = document.createElement("span");
labelSpan.className = "livetrack-panel__label"; labelSpan.className = "livetrack-panel__label";
@@ -100,7 +103,7 @@ export class LiveTrackPanelControl {
} else { } else {
button.append(labelSpan, timeSpan); button.append(labelSpan, timeSpan);
} }
button.addEventListener("click", () => this._toggleTrack(id, button)); if (!unavailable) button.addEventListener("click", () => this._toggleTrack(id, button));
li.appendChild(button); li.appendChild(button);
this._list.appendChild(li); this._list.appendChild(li);
}); });
+38 -2
View File
@@ -18,7 +18,10 @@ export class LocateControl {
this._gnssControl = gnssControl; this._gnssControl = gnssControl;
this._active = false; this._active = false;
this._marker = null; this._marker = null;
this._circleMarker = null;
this._accuracy = null;
this._watchId = null; this._watchId = null;
this._onMapChange = () => this._updateCircleRadius();
} }
onAdd(map) { onAdd(map) {
@@ -102,13 +105,15 @@ export class LocateControl {
this._active = true; this._active = true;
this._button.classList.add("maplibregl-ctrl-geolocate-active"); this._button.classList.add("maplibregl-ctrl-geolocate-active");
this._watchId = navigator.geolocation.watchPosition( this._watchId = navigator.geolocation.watchPosition(
(position) => this._setPosition(position.coords.longitude, position.coords.latitude), (position) => this._setPosition(position.coords.longitude, position.coords.latitude, position.coords.accuracy),
() => { () => {
window.alert("Could not determine your location."); window.alert("Could not determine your location.");
this._stop(); this._stop();
}, },
{ enableHighAccuracy: true }, { enableHighAccuracy: true },
); );
this._map.on("zoom", this._onMapChange);
this._map.on("move", this._onMapChange);
} }
// Recenters on every fix, following the position the way GeolocateControl // Recenters on every fix, following the position the way GeolocateControl
@@ -123,16 +128,40 @@ export class LocateControl {
// .gnss-marker — reusing that green for a browser-estimated position too // .gnss-marker — reusing that green for a browser-estimated position too
// looked like this control was somehow still using GNSS data even when // looked like this control was somehow still using GNSS data even when
// it very much wasn't; distinct colors for distinct sources. // it very much wasn't; distinct colors for distinct sources.
_setPosition(lon, lat) { _setPosition(lon, lat, accuracy) {
this._accuracy = accuracy;
if (!this._marker) { if (!this._marker) {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "maplibregl-user-location-dot"; el.className = "maplibregl-user-location-dot";
this._marker = new Marker({ element: el }).setLngLat([lon, lat]).addTo(this._map); this._marker = new Marker({ element: el }).setLngLat([lon, lat]).addTo(this._map);
const circleEl = document.createElement("div");
circleEl.className = "maplibregl-user-location-accuracy-circle";
this._circleMarker = new Marker({ element: circleEl, pitchAlignment: "map" }).setLngLat([lon, lat]).addTo(this._map);
this._map.flyTo({ center: [lon, lat], zoom: Math.max(this._map.getZoom(), 15) }); this._map.flyTo({ center: [lon, lat], zoom: Math.max(this._map.getZoom(), 15) });
} else { } else {
this._marker.setLngLat([lon, lat]); this._marker.setLngLat([lon, lat]);
this._circleMarker.setLngLat([lon, lat]);
this._map.easeTo({ center: [lon, lat] }); this._map.easeTo({ center: [lon, lat] });
} }
this._updateCircleRadius();
}
// Mirrors MapLibre's own GeolocateControl._updateCircleRadiusIfNeeded:
// measures real-world meters per pixel at the marker's position (rather
// than assuming a flat conversion, which breaks away from the equator)
// by projecting a 100px offset back to lng/lat and measuring that
// distance, then sizes the circle element to the accuracy radius in
// that many pixels.
_updateCircleRadius() {
if (!this._circleMarker || !this._accuracy) return;
const lngLat = this._marker.getLngLat();
const point = this._map.project(lngLat);
const offsetLngLat = this._map.unproject([point.x + 100, point.y]);
const metersPerPixel = lngLat.distanceTo(offsetLngLat) / 100;
const diameter = (2 * this._accuracy) / metersPerPixel;
this._circleMarker.getElement().style.width = `${diameter.toFixed(2)}px`;
this._circleMarker.getElement().style.height = `${diameter.toFixed(2)}px`;
} }
_stop() { _stop() {
@@ -142,9 +171,16 @@ export class LocateControl {
navigator.geolocation.clearWatch(this._watchId); navigator.geolocation.clearWatch(this._watchId);
this._watchId = null; this._watchId = null;
} }
this._map.off("zoom", this._onMapChange);
this._map.off("move", this._onMapChange);
if (this._marker) { if (this._marker) {
this._marker.remove(); this._marker.remove();
this._marker = null; this._marker = null;
} }
if (this._circleMarker) {
this._circleMarker.remove();
this._circleMarker = null;
}
this._accuracy = null;
} }
} }
+29
View File
@@ -53,6 +53,35 @@ class LiveTrackStoreTest < Minitest::Test
assert_includes ids, "old_with_summary" assert_includes ids, "old_with_summary"
end end
def test_stale_ended_finds_old_ended_sessions_but_not_active_recent_or_already_flagged_ones
@store.add("old_ended", "tok1")
@store.set_summary("old_ended", { "active" => false, "end" => (Time.now.utc - 25 * 60 * 60).iso8601 })
@store.add("recent_ended", "tok2")
@store.set_summary("recent_ended", { "active" => false, "end" => Time.now.utc.iso8601 })
@store.add("still_active", "tok3")
@store.set_summary("still_active", { "active" => true })
@store.add("already_flagged", "tok4")
@store.set_summary("already_flagged", { "active" => false, "end" => (Time.now.utc - 25 * 60 * 60).iso8601 })
@store.mark_unavailable("already_flagged")
ids = @store.stale_ended(24 * 60 * 60).map { |s| s["id"] }
assert_equal ["old_ended"], ids
end
def test_mark_unavailable_flags_the_session_without_touching_its_summary
@store.add("abc123", "tok-abc")
@store.set_summary("abc123", { "session_name" => "Test Ride" })
@store.mark_unavailable("abc123")
session = @store.find("abc123")
assert_equal false, session["map_available"]
assert_equal({ "session_name" => "Test Ride" }, session["summary"])
end
def test_data_survives_reload_from_disk def test_data_survives_reload_from_disk
@store.add("abc123", "tok-abc") @store.add("abc123", "tok-abc")
path = @store.instance_variable_get(:@path) path = @store.instance_variable_get(:@path)