Detect a LiveTrack session ending the moment Garmin confirms it, not 30min later
CI / check (push) Successful in 18s
CI / deploy (push) Successful in 12s

Watched the session from the last two commits actually get saved:
Garmin updated its `end` field from the future 24h placeholder to
within a second of the last point's own time, immediately — much
faster than the recency-of-last-point fallback alone would have
noticed (that only flips after ACTIVE_WITHIN with no new points, so a
just-saved session with a last point seconds old would still read as
active for up to 30 more minutes).

Now checks end-already-in-the-past first (authoritative and near
instant when Garmin has set it), falling back to point recency only
when end still shows the future placeholder — the case an abandoned,
never-saved session never updates.
This commit is contained in:
randy committed 2026-09-20 16:55:57 +02:00
1 parent 3106f8c103
commit b0c93d10d7
2 files changed
+41 -13

No files matched your search

+25 -13
View File
@@ -56,16 +56,25 @@ module Maptool
# doesn't set `end` until the activity actually stops — disproven # doesn't set `end` until the activity actually stops — disproven
# against a real still-recording session (2026-09-20): `end` was # against a real still-recording session (2026-09-20): `end` was
# already non-nil, set to exactly `start + 24h`, the share link's own # 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` # expiry, not an actual end time. Two signals now, both verified live
# instead reflects its real end — ~2h after start for the 2026-09-06 # against that same session:
# session this was originally, and only, verified against). Recency of #
# the last track point is a directly-observable signal instead of # * `end` itself, once it's actually in the past — watched the same
# trying to reverse-engineer what a given `end` value means: Garmin # session update it from that 24h placeholder to within a second of
# posts one roughly every `postTrackPointFrequency` seconds (~15, # the last point's own time, immediately after the activity was
# observed) while genuinely still recording, so a point older than # saved on the watch. Authoritative and near-instant when present,
# ACTIVE_WITHIN means the activity has actually stopped (or, at worst, # so this is checked first.
# lost signal for a while — same "looks ended until it isn't" behavior # * failing that (still showing the future placeholder — an abandoned
# Garmin's own LiveTrack page shows in that case). # 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 ACTIVE_WITHIN = 30 * 60 # seconds
def self.summarize(result) def self.summarize(result)
@@ -76,16 +85,19 @@ module Maptool
"activity_type" => last["activityType"], "activity_type" => last["activityType"],
"distance_meters" => last["totalDistanceMeters"], "distance_meters" => last["totalDistanceMeters"],
"duration_secs" => last["totalDurationSecs"], "duration_secs" => last["totalDurationSecs"],
"active" => active?(last["dateTime"]), "active" => active?(last["dateTime"], session["end"]),
"start" => session["start"], "start" => session["start"],
"end" => session["end"], "end" => session["end"],
} }
end end
def self.active?(last_point_time) def self.active?(last_point_time, session_end)
return false unless last_point_time 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 rescue ArgumentError, TypeError
false false
end end
+16
View File
@@ -71,6 +71,22 @@ class LiveTrackClientTest < Minitest::Test
assert Maptool::LiveTrackClient.summarize(result)["active"] assert Maptool::LiveTrackClient.summarize(result)["active"]
end 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 def test_finds_the_planned_course_alongside_the_actual_track
payloads = @client.send(:parse_payloads, FAKE_PAGE) 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? }) course = @client.send(:clean, @client.send(:search, payloads) { |n| n["courses"].first if n["courses"].is_a?(Array) && !n["courses"].empty? })