diff --git a/lib/maptool/livetrack_client.rb b/lib/maptool/livetrack_client.rb index c561d34..17ba7a0 100644 --- a/lib/maptool/livetrack_client.rb +++ b/lib/maptool/livetrack_client.rb @@ -56,16 +56,25 @@ module Maptool # 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). + # expiry, not an actual end time. Two signals now, both verified live + # against that same session: + # + # * `end` itself, once it's actually in the past — watched the same + # session update it from that 24h placeholder to within a second of + # the last point's own time, immediately after the activity was + # saved on the watch. Authoritative and near-instant when present, + # so this is checked first. + # * failing that (still showing the future placeholder — an abandoned + # session never gets saved, so `end` never moves), recency of the + # last track point: Garmin posts one roughly every + # `postTrackPointFrequency` seconds (~15, observed) while genuinely + # still recording, so one older than ACTIVE_WITHIN means signal was + # lost a while ago (same "looks ended until it isn't" behavior + # Garmin's own LiveTrack page shows in that case). + # + # Both must agree the session is live — `end` already in the past + # means stopped even if the last point is only seconds old (exactly + # the save-immediately-after-the-last-point case above). ACTIVE_WITHIN = 30 * 60 # seconds def self.summarize(result) @@ -76,16 +85,19 @@ module Maptool "activity_type" => last["activityType"], "distance_meters" => last["totalDistanceMeters"], "duration_secs" => last["totalDurationSecs"], - "active" => active?(last["dateTime"]), + "active" => active?(last["dateTime"], session["end"]), "start" => session["start"], "end" => session["end"], } end - def self.active?(last_point_time) + def self.active?(last_point_time, session_end) return false unless last_point_time - Time.parse(last_point_time) > Time.now.utc - ACTIVE_WITHIN + now = Time.now.utc + return false if session_end && Time.parse(session_end) <= now + + Time.parse(last_point_time) > now - ACTIVE_WITHIN rescue ArgumentError, TypeError false end diff --git a/test/test_livetrack_client.rb b/test/test_livetrack_client.rb index 59ec304..b0a9dc8 100644 --- a/test/test_livetrack_client.rb +++ b/test/test_livetrack_client.rb @@ -71,6 +71,22 @@ class LiveTrackClientTest < Minitest::Test assert Maptool::LiveTrackClient.summarize(result)["active"] end + # Verified live the same day: Garmin updates a real session's `end` from + # the future placeholder to within a second of the last point's own + # time immediately once the activity is saved on the watch — so a fresh + # last point alone isn't enough, `end` already being in the past has to + # win even then. + def test_summarize_marks_inactive_once_end_is_in_the_past_even_with_a_fresh_point + payloads = @client.send(:parse_payloads, FAKE_PAGE) + points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }) + points[-1] = points[-1].merge("dateTime" => Time.now.utc.iso8601) + session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] }) + .merge("end" => (Time.now.utc - 1).iso8601) + result = Maptool::LiveTrackClient::Result.new(points: points, session: session) + + refute Maptool::LiveTrackClient.summarize(result)["active"] + end + def test_finds_the_planned_course_alongside_the_actual_track payloads = @client.send(:parse_payloads, FAKE_PAGE) course = @client.send(:clean, @client.send(:search, payloads) { |n| n["courses"].first if n["courses"].is_a?(Array) && !n["courses"].empty? })