Summarize LiveTrack sessions and prune stale ones
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:
1 parent
f4b5237f01
commit
0ee98d8f07
5 files changed
+175
-47
No files matched your search
@@ -254,11 +254,13 @@ module Maptool
|
|||||||
halt 404, json(error: "session has no token") if session["token"].to_s.empty?
|
halt 404, json(error: "session has no token") if session["token"].to_s.empty?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
points = LiveTrackClient.track_points(session["id"], session["token"])
|
result = LiveTrackClient.fetch(session["id"], session["token"])
|
||||||
rescue LiveTrackClient::FetchError => e
|
rescue LiveTrackClient::FetchError => e
|
||||||
halt 502, json(error: "could not fetch track: #{e.message}")
|
halt 502, json(error: "could not fetch track: #{e.message}")
|
||||||
end
|
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"]] }
|
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?
|
halt 502, json(error: "track has no positions") if coordinates.empty?
|
||||||
last = points.last
|
last = points.last
|
||||||
|
|||||||
@@ -2,31 +2,58 @@ require "net/http"
|
|||||||
require "json"
|
require "json"
|
||||||
|
|
||||||
module Maptool
|
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
|
# 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
|
# behind Cloudflare bot protection that 403s a plain HTTP client regardless
|
||||||
# of headers (checked — not just a missing User-Agent). The session page
|
# 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
|
# itself isn't blocked, though, and Garmin's Next.js frontend renders it
|
||||||
# server-side, embedding the full track (as React Server Component data,
|
# server-side, embedding both the full track and the session's own details
|
||||||
# via `self.__next_f.push(...)` calls in the HTML) rather than fetching it
|
# (as React Server Component data, via `self.__next_f.push(...)` calls in
|
||||||
# client-side. So: fetch the page, and pull the track out of that instead
|
# the HTML) rather than fetching them client-side. So: fetch the page, and
|
||||||
# of the API it would otherwise call.
|
# pull both out of that instead of the API it would otherwise call.
|
||||||
class LiveTrackClient
|
class LiveTrackClient
|
||||||
class FetchError < StandardError; end
|
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"
|
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
|
PUSH_PATTERN = /self\.__next_f\.push\((\[.*?\])\)<\/script>/m
|
||||||
|
|
||||||
def self.track_points(session_id, token)
|
Result = Struct.new(:points, :session, keyword_init: true)
|
||||||
new.track_points(session_id, token)
|
|
||||||
|
def self.fetch(session_id, token)
|
||||||
|
new.fetch(session_id, token)
|
||||||
end
|
end
|
||||||
|
|
||||||
def track_points(session_id, token)
|
# `points`: array of track point hashes (position/dateTime/speed/...).
|
||||||
points = find_track_points(fetch_html(session_id, token))
|
# `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
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -55,34 +82,33 @@ module Maptool
|
|||||||
raise FetchError, e.message
|
raise FetchError, e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
# Next.js streams several of these RSC payloads per page; the track
|
# Next.js streams several of these RSC payloads per page, each an
|
||||||
# points live inside one of them, under some data-fetching library's own
|
# independently-parseable `[lineId, "<json>"]` pair once its line-id
|
||||||
# nesting (React Query, at the time this was written) that isn't worth
|
# prefix is stripped off.
|
||||||
# depending on the exact shape of — so this just walks every payload
|
def parse_payloads(html)
|
||||||
# looking for a "trackPoints" array, wherever it ends up.
|
html.scan(PUSH_PATTERN).filter_map do |(raw)|
|
||||||
def find_track_points(html)
|
|
||||||
html.scan(PUSH_PATTERN).each do |(raw)|
|
|
||||||
push = safe_parse(raw)
|
push = safe_parse(raw)
|
||||||
next unless push.is_a?(Array) && push[1].is_a?(String)
|
next unless push.is_a?(Array) && push[1].is_a?(String)
|
||||||
|
|
||||||
_line_id, json = push[1].split(":", 2)
|
_line_id, json = push[1].split(":", 2)
|
||||||
next unless json
|
json && safe_parse(json)
|
||||||
|
|
||||||
found = search_track_points(safe_parse(json))
|
|
||||||
return found if found
|
|
||||||
end
|
end
|
||||||
nil
|
|
||||||
end
|
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
|
case node
|
||||||
when Hash
|
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
|
nil
|
||||||
when Array
|
when Array
|
||||||
node.each { |value| (found = search_track_points(value)) and return found }
|
node.each { |value| (found = search(value, &block)) and return found }
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -101,15 +127,27 @@ if __FILE__ == $0
|
|||||||
fake_page = <<~HTML
|
fake_page = <<~HTML
|
||||||
<html><body>
|
<html><body>
|
||||||
<script>self.__next_f.push([1,"6:[\\"$\\",\\"div\\",null,{}]"])</script>
|
<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>
|
</body></html>
|
||||||
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 2 points" unless points&.size == 2
|
||||||
raise "expected first point lat 48.1" unless points[0]["position"]["lat"] == 48.1
|
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"
|
puts "ok"
|
||||||
end
|
end
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
require "json"
|
require "json"
|
||||||
require "fileutils"
|
require "fileutils"
|
||||||
|
require "time"
|
||||||
|
|
||||||
module Maptool
|
module Maptool
|
||||||
# JSON-file-backed store of Garmin LiveTrack sessions, appended to by a
|
# JSON-file-backed store of Garmin LiveTrack sessions, appended to by a
|
||||||
# webhook whenever a new LiveTrack email arrives (see docs/deployment.adoc).
|
# 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>,
|
# LiveTrackClient) — Garmin's session URL is /session/<id>/token/<token>,
|
||||||
# and the id alone isn't enough. Validity/expiry isn't tracked here —
|
# and the id alone isn't enough.
|
||||||
# whoever fetches the track finds out then.
|
#
|
||||||
|
# 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
|
class LiveTrackStore
|
||||||
|
UNSUMMARIZED_TTL = 48 * 60 * 60 # seconds
|
||||||
|
|
||||||
def initialize(path)
|
def initialize(path)
|
||||||
@path = path
|
@path = path
|
||||||
@mutex = Mutex.new
|
@mutex = Mutex.new
|
||||||
@@ -16,27 +25,62 @@ module Maptool
|
|||||||
File.write(@path, "[]") unless File.exist?(@path)
|
File.write(@path, "[]") unless File.exist?(@path)
|
||||||
end
|
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)
|
def add(session_id, token)
|
||||||
@mutex.synchronize do
|
@mutex.synchronize do
|
||||||
sessions = read
|
sessions = prune(read)
|
||||||
|
existing = sessions.find { |s| s["id"] == session_id }
|
||||||
sessions.reject! { |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 })
|
sessions.unshift(
|
||||||
File.write(@path, JSON.pretty_generate(sessions))
|
"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
|
||||||
end
|
end
|
||||||
|
|
||||||
# Most recently received first.
|
# Most recently received first.
|
||||||
def list
|
def list
|
||||||
read
|
prune(read)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find(session_id)
|
def find(session_id)
|
||||||
read.find { |s| s["id"] == session_id }
|
list.find { |s| s["id"] == session_id }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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
|
def read
|
||||||
JSON.parse(File.read(@path))
|
JSON.parse(File.read(@path))
|
||||||
rescue Errno::ENOENT, JSON::ParserError
|
rescue Errno::ENOENT, JSON::ParserError
|
||||||
@@ -58,14 +102,29 @@ if __FILE__ == $0
|
|||||||
raise "expected newest first" unless store.list.first["id"] == "def456"
|
raise "expected newest first" unless store.list.first["id"] == "def456"
|
||||||
raise "expected token stored" unless store.find("abc123")["token"] == "tok-abc"
|
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 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")
|
store.add("abc123", "tok-abc-2")
|
||||||
raise "expected re-added session moved to front, not duplicated" \
|
raise "expected re-added session moved to front, not duplicated" \
|
||||||
unless store.list.map { |s| s["id"] } == %w[abc123 def456]
|
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"
|
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"))
|
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
|
end
|
||||||
|
|
||||||
puts "ok"
|
puts "ok"
|
||||||
|
|||||||
@@ -471,13 +471,16 @@ html, body {
|
|||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
.livetrack-panel__id {
|
.livetrack-panel__label {
|
||||||
font-family: monospace;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.livetrack-panel__label--id {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.livetrack-panel__time {
|
.livetrack-panel__time {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #888;
|
color: #888;
|
||||||
|
|||||||
+31
-5
@@ -68,7 +68,7 @@ export class LiveTrackPanelControl {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions.forEach(({ id, received_at }) => {
|
sessions.forEach(({ id, received_at, summary }) => {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.className = "livetrack-panel__item";
|
li.className = "livetrack-panel__item";
|
||||||
|
|
||||||
@@ -76,16 +76,18 @@ export class LiveTrackPanelControl {
|
|||||||
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;
|
||||||
|
|
||||||
const idSpan = document.createElement("span");
|
const labelSpan = document.createElement("span");
|
||||||
idSpan.className = "livetrack-panel__id";
|
labelSpan.className = "livetrack-panel__label";
|
||||||
idSpan.textContent = id;
|
labelSpan.classList.toggle("livetrack-panel__label--id", !summary);
|
||||||
|
labelSpan.textContent = summary ? summaryLabel(summary) : id;
|
||||||
|
|
||||||
const timeSpan = document.createElement("span");
|
const timeSpan = document.createElement("span");
|
||||||
timeSpan.className = "livetrack-panel__time";
|
timeSpan.className = "livetrack-panel__time";
|
||||||
timeSpan.textContent = new Date(received_at).toLocaleString();
|
timeSpan.textContent = new Date(received_at).toLocaleString();
|
||||||
|
|
||||||
button.append(idSpan, timeSpan);
|
button.append(labelSpan, timeSpan);
|
||||||
button.addEventListener("click", () => this._toggleTrack(id, button));
|
button.addEventListener("click", () => this._toggleTrack(id, button));
|
||||||
li.appendChild(button);
|
li.appendChild(button);
|
||||||
this._list.appendChild(li);
|
this._list.appendChild(li);
|
||||||
@@ -110,6 +112,10 @@ export class LiveTrackPanelControl {
|
|||||||
this._showTrack(this._geojson);
|
this._showTrack(this._geojson);
|
||||||
this._activeSessionId = id;
|
this._activeSessionId = id;
|
||||||
button.classList.add("active");
|
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 {
|
} finally {
|
||||||
button.classList.remove("loading");
|
button.classList.remove("loading");
|
||||||
}
|
}
|
||||||
@@ -170,3 +176,23 @@ export class LiveTrackPanelControl {
|
|||||||
if (this._map.getSource(TRACK_SOURCE)) this._map.removeSource(TRACK_SOURCE);
|
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`;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user