diff --git a/app.rb b/app.rb index 9d24cb4..6a2c9c3 100644 --- a/app.rb +++ b/app.rb @@ -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 diff --git a/lib/maptool/livetrack_client.rb b/lib/maptool/livetrack_client.rb index 797b565..52465f7 100644 --- a/lib/maptool/livetrack_client.rb +++ b/lib/maptool/livetrack_client.rb @@ -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, ""]` 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 - 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, "").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, "")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) } + raise "expected nil when no trackPoints present" unless no_points.nil? puts "ok" end diff --git a/lib/maptool/livetrack_store.rb b/lib/maptool/livetrack_store.rb index 21be205..a700896 100644 --- a/lib/maptool/livetrack_store.rb +++ b/lib/maptool/livetrack_store.rb @@ -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//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" diff --git a/public/css/style.css b/public/css/style.css index 774be63..e31ba0f 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; diff --git a/public/js/livetrack.js b/public/js/livetrack.js index c54e900..905aa6b 100644 --- a/public/js/livetrack.js +++ b/public/js/livetrack.js @@ -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`; +}