diff --git a/lib/maptool/livetrack_client.rb b/lib/maptool/livetrack_client.rb
index 52465f7..6679622 100644
--- a/lib/maptool/livetrack_client.rb
+++ b/lib/maptool/livetrack_client.rb
@@ -43,6 +43,11 @@ module Maptool
# 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.
+ #
+ # "active": Garmin doesn't set a session's `end` until the activity is
+ # actually stopped, so its absence means the session is still recording
+ # — inferred from the one (already-ended) session this was built
+ # against, not verified against a session actually caught mid-activity.
def self.summarize(result)
last = result.points.last || {}
session = result.session || {}
@@ -51,6 +56,7 @@ module Maptool
"activity_type" => last["activityType"],
"distance_meters" => last["totalDistanceMeters"],
"duration_secs" => last["totalDurationSecs"],
+ "active" => session["end"].nil?,
"start" => session["start"],
"end" => session["end"],
}
@@ -145,6 +151,11 @@ if __FILE__ == $0
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"
+ raise "expected inactive: session has an end" if summary["active"]
+
+ ongoing_session = session.merge("end" => nil)
+ ongoing_summary = Maptool::LiveTrackClient.summarize(Maptool::LiveTrackClient::Result.new(points: result.points, session: ongoing_session))
+ raise "expected active: session has no end" unless ongoing_summary["active"]
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?
diff --git a/public/css/style.css b/public/css/style.css
index e31ba0f..72ef81f 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -446,8 +446,7 @@ html, body {
.livetrack-panel__row {
display: flex;
align-items: center;
- justify-content: space-between;
- gap: 8px;
+ gap: 6px;
width: 100%;
padding: 6px 4px;
border: none;
@@ -472,6 +471,8 @@ html, body {
}
.livetrack-panel__label {
+ flex: 1;
+ min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -481,6 +482,23 @@ html, body {
font-family: monospace;
}
+.livetrack-badge {
+ flex-shrink: 0;
+ padding: 2px 6px;
+ border-radius: 3px;
+ background: #e5e5e5;
+ color: #666;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.livetrack-badge--live {
+ background: #d7f3e3;
+ color: #1b7a43;
+}
+
.livetrack-panel__time {
flex-shrink: 0;
color: #888;
diff --git a/public/js/livetrack.js b/public/js/livetrack.js
index f0f779e..13c9822 100644
--- a/public/js/livetrack.js
+++ b/public/js/livetrack.js
@@ -90,7 +90,11 @@ export class LiveTrackPanelControl {
timeSpan.className = "livetrack-panel__time";
timeSpan.textContent = new Date(received_at).toLocaleString();
- button.append(labelSpan, timeSpan);
+ if (summary) {
+ button.append(statusBadge(summary.active), labelSpan, timeSpan);
+ } else {
+ button.append(labelSpan, timeSpan);
+ }
button.addEventListener("click", () => this._toggleTrack(id, button));
li.appendChild(button);
this._list.appendChild(li);
@@ -213,8 +217,9 @@ export class LiveTrackPanelControl {
// data — session_name in particular is user-supplied (whatever the LiveTrack
// session was named on the watch/app) — so escape before it lands in HTML,
// same as GpxPanelControl's popups do for GPX file contents.
-function popupHtml({ session_name, activity_type, distance_meters, duration_secs, time, speed_meters_per_sec, heart_rate_beats_per_min }) {
- const parts = [`${escapeHtml(session_name || "LiveTrack session")}`];
+function popupHtml({ session_name, activity_type, distance_meters, duration_secs, active, time, speed_meters_per_sec, heart_rate_beats_per_min }) {
+ const status = active ? `Live` : `Ended`;
+ const parts = [`${escapeHtml(session_name || "LiveTrack session")} ${status}`];
const stats = [];
if (activity_type) stats.push(escapeHtml(titleCase(activity_type)));
@@ -243,6 +248,14 @@ function escapeHtml(text) {
return div.innerHTML;
}
+function statusBadge(active) {
+ const badge = document.createElement("span");
+ badge.className = "livetrack-badge";
+ badge.classList.toggle("livetrack-badge--live", !!active);
+ badge.textContent = active ? "Live" : "Ended";
+ return badge;
+}
+
function summaryLabel({ session_name, activity_type, distance_meters, duration_secs }) {
const parts = [];
if (activity_type) parts.push(titleCase(activity_type));