From 0c4ee97368d418f1ed7d846a81fa4958ce71d73a Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Tue, 8 Sep 2026 14:19:15 +0200 Subject: [PATCH] Flag LiveTrack sessions whose map Garmin no longer serves 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. --- app.rb | 19 +++++++++++++++++ lib/maptool/livetrack_store.rb | 39 ++++++++++++++++++++++++++++++++++ public/css/style.css | 5 +++++ public/js/livetrack.js | 9 +++++--- test/test_livetrack_store.rb | 29 +++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 3 deletions(-) diff --git a/app.rb b/app.rb index 19a3568..80e3ff8 100644 --- a/app.rb +++ b/app.rb @@ -325,6 +325,25 @@ module Maptool json sessions: LIVETRACK_STORE.list 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 # request no matter what headers it gets — see LiveTrackClient — so this # fetches the session page itself and pulls the track out of that. diff --git a/lib/maptool/livetrack_store.rb b/lib/maptool/livetrack_store.rb index a94e0fd..24a97f0 100644 --- a/lib/maptool/livetrack_store.rb +++ b/lib/maptool/livetrack_store.rb @@ -55,6 +55,31 @@ module Maptool 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. def list prune(read) @@ -77,6 +102,20 @@ module Maptool Time.now.utc # malformed/missing timestamp: don't let it block pruning 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) File.write(@path, JSON.pretty_generate(sessions)) end diff --git a/public/css/style.css b/public/css/style.css index c808189..2cf5b12 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -781,6 +781,11 @@ html, body { cursor: wait; } +.livetrack-panel__row:disabled { + opacity: 0.5; + cursor: default; +} + .livetrack-panel__label { flex: 1; min-width: 0; diff --git a/public/js/livetrack.js b/public/js/livetrack.js index 0dbbd58..d1b428a 100644 --- a/public/js/livetrack.js +++ b/public/js/livetrack.js @@ -76,15 +76,18 @@ export class LiveTrackPanelControl { return; } - sessions.forEach(({ id, received_at, summary }) => { + sessions.forEach(({ id, received_at, summary, map_available }) => { const li = document.createElement("li"); li.className = "livetrack-panel__item"; + const unavailable = map_available === false; + const button = document.createElement("button"); button.type = "button"; button.className = "livetrack-panel__row"; 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"); labelSpan.className = "livetrack-panel__label"; @@ -100,7 +103,7 @@ export class LiveTrackPanelControl { } else { button.append(labelSpan, timeSpan); } - button.addEventListener("click", () => this._toggleTrack(id, button)); + if (!unavailable) button.addEventListener("click", () => this._toggleTrack(id, button)); li.appendChild(button); this._list.appendChild(li); }); diff --git a/test/test_livetrack_store.rb b/test/test_livetrack_store.rb index 41ca1c7..d858695 100644 --- a/test/test_livetrack_store.rb +++ b/test/test_livetrack_store.rb @@ -53,6 +53,35 @@ class LiveTrackStoreTest < Minitest::Test assert_includes ids, "old_with_summary" 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 @store.add("abc123", "tok-abc") path = @store.instance_variable_get(:@path)