diff --git a/lib/maptool/livetrack_client.rb b/lib/maptool/livetrack_client.rb index 9f1b494..a28b944 100644 --- a/lib/maptool/livetrack_client.rb +++ b/lib/maptool/livetrack_client.rb @@ -1,5 +1,6 @@ require "net/http" require "json" +require "time" module Maptool # Fetches a Garmin LiveTrack session's track and metadata. @@ -44,10 +45,22 @@ module Maptool # 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. + # "active": originally `session["end"].nil?`, on the assumption Garmin + # doesn't set `end` until the activity actually stops — disproven + # against a real still-recording session (2026-09-20): `end` was + # already non-nil, set to exactly `start + 24h`, the share link's own + # expiry, not an actual end time (a genuinely ended session's `end` + # instead reflects its real end — ~2h after start for the 2026-09-06 + # session this was originally, and only, verified against). Recency of + # the last track point is a directly-observable signal instead of + # trying to reverse-engineer what a given `end` value means: Garmin + # posts one roughly every `postTrackPointFrequency` seconds (~15, + # observed) while genuinely still recording, so a point older than + # ACTIVE_WITHIN means the activity has actually stopped (or, at worst, + # lost signal for a while — same "looks ended until it isn't" behavior + # Garmin's own LiveTrack page shows in that case). + ACTIVE_WITHIN = 30 * 60 # seconds + def self.summarize(result) last = result.points.last || {} session = result.session || {} @@ -56,12 +69,20 @@ module Maptool "activity_type" => last["activityType"], "distance_meters" => last["totalDistanceMeters"], "duration_secs" => last["totalDurationSecs"], - "active" => session["end"].nil?, + "active" => active?(last["dateTime"]), "start" => session["start"], "end" => session["end"], } end + def self.active?(last_point_time) + return false unless last_point_time + + Time.parse(last_point_time) > Time.now.utc - ACTIVE_WITHIN + rescue ArgumentError, TypeError + false + end + private # The RSC payload serializes an absent field as the literal string diff --git a/test/test_livetrack_client.rb b/test/test_livetrack_client.rb index d699241..8c6ffa2 100644 --- a/test/test_livetrack_client.rb +++ b/test/test_livetrack_client.rb @@ -49,13 +49,22 @@ class LiveTrackClientTest < Minitest::Test assert_equal "Test Ride", summary["session_name"] assert_equal "CYCLING", summary["activity_type"] assert_equal 5000, summary["distance_meters"] - refute summary["active"], "session has an end timestamp" + refute summary["active"], "last track point is long in the past" end - def test_summarize_marks_active_when_session_has_no_end + # Not session["end"].nil? (the original signal, before 2026-09-20 — see + # LiveTrackClient's own comment): verified live against a real, + # still-recording session that day, Garmin had already set `end` to + # exactly start + 24h (the share link's own expiry, not an end time), + # so that check called an active session "ended". + def test_summarize_marks_active_when_last_point_is_recent payloads = @client.send(:parse_payloads, FAKE_PAGE) points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }) - session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] }).merge("end" => nil) + points[-1] = points[-1].merge("dateTime" => Time.now.utc.iso8601) + # end present and in the future, same shape Garmin actually sends for a + # session that's still live — active must come from the point, not this. + session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] }) + .merge("end" => (Time.now.utc + 24 * 60 * 60).iso8601) result = Maptool::LiveTrackClient::Result.new(points: points, session: session) assert Maptool::LiveTrackClient.summarize(result)["active"]