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.
This commit is contained in:
randy committed 2026-09-08 14:19:15 +02:00
1 parent 141b7a08ce
commit 0c4ee97368
5 files changed
+98 -3

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);
}); });
+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)