Summarize LiveTrack sessions and prune stale ones
CI / check (push) Successful in 48s
CI / deploy (push) Successful in 23s

Fetching a session's track now also grabs its summary (name, activity
type, distance, duration) from the same page and stores it — sessions
the panel has actually shown replace their raw UUID with that summary,
and are kept indefinitely regardless of age. A session nobody ever
opened (no summary) is more likely test/dead data than something worth
keeping, so it's dropped automatically after 48h.
This commit is contained in:
randy committed 2026-09-06 22:18:25 +02:00
1 parent f4b5237f01
commit 0ee98d8f07
5 files changed
+175 -47

No files matched your search

+3 -1
View File
@@ -254,11 +254,13 @@ module Maptool
halt 404, json(error: "session has no token") if session["token"].to_s.empty?
begin
points = LiveTrackClient.track_points(session["id"], session["token"])
result = LiveTrackClient.fetch(session["id"], session["token"])
rescue LiveTrackClient::FetchError => e
halt 502, json(error: "could not fetch track: #{e.message}")
end
LIVETRACK_STORE.set_summary(session["id"], LiveTrackClient.summarize(result))
points = result.points
coordinates = points.filter_map { |p| p.dig("position", "lon") && p.dig("position", "lat") && [p["position"]["lon"], p["position"]["lat"]] }
halt 502, json(error: "track has no positions") if coordinates.empty?
last = points.last
+67 -29
View File
@@ -2,31 +2,58 @@ require "net/http"
require "json"
module Maptool
# Fetches a Garmin LiveTrack session's track points.
# Fetches a Garmin LiveTrack session's track and metadata.
#
# There's no usable REST call for this: livetrack.garmin.com/api/v2/... is
# behind Cloudflare bot protection that 403s a plain HTTP client regardless
# of headers (checked — not just a missing User-Agent). The session page
# itself isn't blocked, though, and Garmin's Next.js frontend renders it
# server-side, embedding the full track (as React Server Component data,
# via `self.__next_f.push(...)` calls in the HTML) rather than fetching it
# client-side. So: fetch the page, and pull the track out of that instead
# of the API it would otherwise call.
# server-side, embedding both the full track and the session's own details
# (as React Server Component data, via `self.__next_f.push(...)` calls in
# the HTML) rather than fetching them client-side. So: fetch the page, and
# pull both out of that instead of the API it would otherwise call.
class LiveTrackClient
class FetchError < StandardError; end
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
PUSH_PATTERN = /self\.__next_f\.push\((\[.*?\])\)<\/script>/m
def self.track_points(session_id, token)
new.track_points(session_id, token)
Result = Struct.new(:points, :session, keyword_init: true)
def self.fetch(session_id, token)
new.fetch(session_id, token)
end
def track_points(session_id, token)
points = find_track_points(fetch_html(session_id, token))
# `points`: array of track point hashes (position/dateTime/speed/...).
# `session`: hash of session details (sessionName/start/end/...), or nil
# if that part wasn't found even though points were (seen in practice:
# session details are consistently present alongside points, but nothing
# here depends on that holding forever).
def fetch(session_id, token)
payloads = parse_payloads(fetch_html(session_id, token))
points = search(payloads) { |node| node["trackPoints"] if node["trackPoints"].is_a?(Array) }
raise FetchError, "no track points found (session invalid or expired?)" unless points
points.map { |point| clean(point) }
session = search(payloads) { |node| node if node["sessionName"] }
Result.new(points: clean(points), session: clean(session))
end
# Distance/duration/activity type come from the last track point (its
# totals are cumulative for the whole session), name/type from the
# session details — nil-safe throughout since either can be missing.
def self.summarize(result)
last = result.points.last || {}
session = result.session || {}
{
"session_name" => session["sessionName"],
"activity_type" => last["activityType"],
"distance_meters" => last["totalDistanceMeters"],
"duration_secs" => last["totalDurationSecs"],
"start" => session["start"],
"end" => session["end"],
}
end
private
@@ -55,34 +82,33 @@ module Maptool
raise FetchError, e.message
end
# Next.js streams several of these RSC payloads per page; the track
# points live inside one of them, under some data-fetching library's own
# nesting (React Query, at the time this was written) that isn't worth
# depending on the exact shape of — so this just walks every payload
# looking for a "trackPoints" array, wherever it ends up.
def find_track_points(html)
html.scan(PUSH_PATTERN).each do |(raw)|
# Next.js streams several of these RSC payloads per page, each an
# independently-parseable `[lineId, "<json>"]` pair once its line-id
# prefix is stripped off.
def parse_payloads(html)
html.scan(PUSH_PATTERN).filter_map do |(raw)|
push = safe_parse(raw)
next unless push.is_a?(Array) && push[1].is_a?(String)
_line_id, json = push[1].split(":", 2)
next unless json
found = search_track_points(safe_parse(json))
return found if found
json && safe_parse(json)
end
nil
end
def search_track_points(node)
# Walks the payloads looking for a hash the block matches — the payload
# nests under some data-fetching library's own structure (React Query,
# at the time this was written) that isn't worth depending on the exact
# shape of.
def search(node, &block)
case node
when Hash
return node["trackPoints"] if node["trackPoints"].is_a?(Array)
found = block.call(node)
return found if found
node.each_value { |value| (found = search_track_points(value)) and return found }
node.each_value { |value| (found = search(value, &block)) and return found }
nil
when Array
node.each { |value| (found = search_track_points(value)) and return found }
node.each { |value| (found = search(value, &block)) and return found }
nil
end
end
@@ -101,15 +127,27 @@ if __FILE__ == $0
fake_page = <<~HTML
<html><body>
<script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script>
<script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\"}]}]}}}"])</script>
<script>self.__next_f.push([1,"9:{\\"session\\":{\\"sessionName\\":\\"Test Ride\\",\\"start\\":\\"2026-01-01T00:00:00Z\\",\\"end\\":\\"2026-01-01T01:00:00Z\\"}}"])</script>
<script>self.__next_f.push([1,"10:{\\"unrelated\\":true,\\"nested\\":{\\"data\\":{\\"pages\\":[{\\"trackPoints\\":[{\\"position\\":{\\"lat\\":48.1,\\"lon\\":17.2},\\"dateTime\\":\\"2026-01-01T00:00:00Z\\",\\"totalDistanceMeters\\":\\"$undefined\\"},{\\"position\\":{\\"lat\\":48.2,\\"lon\\":17.3},\\"dateTime\\":\\"2026-01-01T00:01:00Z\\",\\"activityType\\":\\"CYCLING\\",\\"totalDistanceMeters\\":5000,\\"totalDurationSecs\\":600}]}]}}}"])</script>
</body></html>
HTML
points = client.send(:find_track_points, fake_page)
payloads = client.send(:parse_payloads, fake_page)
points = client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
raise "expected 2 points" unless points&.size == 2
raise "expected first point lat 48.1" unless points[0]["position"]["lat"] == 48.1
raise "expected nil when no trackPoints present" unless client.send(:find_track_points, "<html></html>").nil?
session = client.send(:search, payloads) { |n| n if n["sessionName"] }
raise "expected session name" unless session["sessionName"] == "Test Ride"
result = Maptool::LiveTrackClient::Result.new(points: client.send(:clean, points), session: client.send(:clean, session))
summary = Maptool::LiveTrackClient.summarize(result)
raise "expected $undefined cleaned to nil" unless result.points[0]["totalDistanceMeters"].nil?
raise "expected summary from last point" unless summary["activity_type"] == "CYCLING" && summary["distance_meters"] == 5000
raise "expected session name in summary" unless summary["session_name"] == "Test Ride"
no_points = client.send(:search, client.send(:parse_payloads, "<html></html>")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }
raise "expected nil when no trackPoints present" unless no_points.nil?
puts "ok"
end
+69 -10
View File
@@ -1,14 +1,23 @@
require "json"
require "fileutils"
require "time"
module Maptool
# JSON-file-backed store of Garmin LiveTrack sessions, appended to by a
# webhook whenever a new LiveTrack email arrives (see docs/deployment.adoc).
# The token is what actually lets us fetch the session's track later (see
# The token is what actually lets us fetch the session's track (see
# LiveTrackClient) — Garmin's session URL is /session/<id>/token/<token>,
# and the id alone isn't enough. Validity/expiry isn't tracked here —
# whoever fetches the track finds out then.
# and the id alone isn't enough.
#
# A session's own summary (see LiveTrackClient.summarize), once fetched,
# is kept indefinitely — it's small, and worth keeping as a record of the
# activity even after Garmin stops serving the live session. A session
# nobody ever opened (so has no summary) is just clutter — likely never
# even a real one, going by how this webhook has been tested — and is
# dropped after UNSUMMARIZED_TTL.
class LiveTrackStore
UNSUMMARIZED_TTL = 48 * 60 * 60 # seconds
def initialize(path)
@path = path
@mutex = Mutex.new
@@ -16,27 +25,62 @@ module Maptool
File.write(@path, "[]") unless File.exist?(@path)
end
# Records `session_id`/`token`, moving it to the front if already present.
# Records `session_id`/`token`, moving it to the front if already
# present (its summary, if any, carries over).
def add(session_id, token)
@mutex.synchronize do
sessions = read
sessions = prune(read)
existing = sessions.find { |s| s["id"] == session_id }
sessions.reject! { |s| s["id"] == session_id }
sessions.unshift({ "id" => session_id, "token" => token, "received_at" => Time.now.utc.iso8601 })
File.write(@path, JSON.pretty_generate(sessions))
sessions.unshift(
"id" => session_id,
"token" => token,
"received_at" => Time.now.utc.iso8601,
"summary" => existing && existing["summary"]
)
write(sessions)
end
end
# Attaches a summary (see LiveTrackClient.summarize) to a stored session,
# exempting it from UNSUMMARIZED_TTL from here on.
def set_summary(session_id, summary)
@mutex.synchronize do
sessions = read
session = sessions.find { |s| s["id"] == session_id }
return unless session
session["summary"] = summary
write(sessions)
end
end
# Most recently received first.
def list
read
prune(read)
end
def find(session_id)
read.find { |s| s["id"] == session_id }
list.find { |s| s["id"] == session_id }
end
private
def prune(sessions)
cutoff = Time.now.utc - UNSUMMARIZED_TTL
sessions.select { |s| s["summary"] || received_at(s) > cutoff }
end
def received_at(session)
Time.parse(session["received_at"])
rescue ArgumentError, TypeError
Time.now.utc # malformed/missing timestamp: don't let it block pruning
end
def write(sessions)
File.write(@path, JSON.pretty_generate(sessions))
end
def read
JSON.parse(File.read(@path))
rescue Errno::ENOENT, JSON::ParserError
@@ -58,14 +102,29 @@ if __FILE__ == $0
raise "expected newest first" unless store.list.first["id"] == "def456"
raise "expected token stored" unless store.find("abc123")["token"] == "tok-abc"
raise "expected unknown id to be nil" unless store.find("nope").nil?
raise "expected no summary yet" unless store.find("abc123")["summary"].nil?
store.add("abc123", "tok-abc-2")
raise "expected re-added session moved to front, not duplicated" \
unless store.list.map { |s| s["id"] } == %w[abc123 def456]
raise "expected token updated on re-add" unless store.find("abc123")["token"] == "tok-abc-2"
store.set_summary("abc123", { "session_name" => "Test Ride" })
raise "expected summary stored" unless store.find("abc123")["summary"] == { "session_name" => "Test Ride" }
store.add("abc123", "tok-abc-3")
raise "expected summary to carry over across re-add" \
unless store.find("abc123")["summary"] == { "session_name" => "Test Ride" }
# Manually age an unsummarized session past the TTL and confirm it gets
# pruned, while a summarized one (however old) survives.
raw = JSON.parse(File.read(File.join(dir, "livetrack.json")))
raw.find { |s| s["id"] == "def456" }["received_at"] = (Time.now.utc - 49 * 60 * 60).iso8601
raw.find { |s| s["id"] == "abc123" }["received_at"] = (Time.now.utc - 1000 * 60 * 60).iso8601
File.write(File.join(dir, "livetrack.json"), JSON.generate(raw))
reloaded = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json"))
raise "expected data to survive reload" unless reloaded.list.size == 2
raise "expected old unsummarized session pruned" if reloaded.list.any? { |s| s["id"] == "def456" }
raise "expected old but summarized session kept" unless reloaded.list.any? { |s| s["id"] == "abc123" }
end
puts "ok"
+5 -2
View File
@@ -471,13 +471,16 @@ html, body {
cursor: wait;
}
.livetrack-panel__id {
font-family: monospace;
.livetrack-panel__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.livetrack-panel__label--id {
font-family: monospace;
}
.livetrack-panel__time {
flex-shrink: 0;
color: #888;
+31 -5
View File
@@ -68,7 +68,7 @@ export class LiveTrackPanelControl {
return;
}
sessions.forEach(({ id, received_at }) => {
sessions.forEach(({ id, received_at, summary }) => {
const li = document.createElement("li");
li.className = "livetrack-panel__item";
@@ -76,16 +76,18 @@ export class LiveTrackPanelControl {
button.type = "button";
button.className = "livetrack-panel__row";
button.classList.toggle("active", id === this._activeSessionId);
button.title = id;
const idSpan = document.createElement("span");
idSpan.className = "livetrack-panel__id";
idSpan.textContent = id;
const labelSpan = document.createElement("span");
labelSpan.className = "livetrack-panel__label";
labelSpan.classList.toggle("livetrack-panel__label--id", !summary);
labelSpan.textContent = summary ? summaryLabel(summary) : id;
const timeSpan = document.createElement("span");
timeSpan.className = "livetrack-panel__time";
timeSpan.textContent = new Date(received_at).toLocaleString();
button.append(idSpan, timeSpan);
button.append(labelSpan, timeSpan);
button.addEventListener("click", () => this._toggleTrack(id, button));
li.appendChild(button);
this._list.appendChild(li);
@@ -110,6 +112,10 @@ export class LiveTrackPanelControl {
this._showTrack(this._geojson);
this._activeSessionId = id;
button.classList.add("active");
// The fetch above also computed and stored a summary server-side
// (see LiveTrackClient.summarize) — reload so this row picks it up
// instead of showing the raw id from here on.
this._loadSessions();
} finally {
button.classList.remove("loading");
}
@@ -170,3 +176,23 @@ export class LiveTrackPanelControl {
if (this._map.getSource(TRACK_SOURCE)) this._map.removeSource(TRACK_SOURCE);
}
}
function summaryLabel({ session_name, activity_type, distance_meters, duration_secs }) {
const parts = [];
if (activity_type) parts.push(titleCase(activity_type));
if (typeof distance_meters === "number") parts.push(`${(distance_meters / 1000).toFixed(1)} km`);
if (typeof duration_secs === "number") parts.push(formatDuration(duration_secs));
const stats = parts.join(" · ");
if (session_name && stats) return `${session_name} — ${stats}`;
return session_name || stats || "Untitled session";
}
function titleCase(text) {
return text.charAt(0) + text.slice(1).toLowerCase();
}
function formatDuration(totalSeconds) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}