diff --git a/docs/explanations/2026-07-18-explanation-check-phone-week-aware.html b/docs/explanations/2026-07-18-explanation-check-phone-week-aware.html new file mode 100644 index 0000000..cce7637 --- /dev/null +++ b/docs/explanations/2026-07-18-explanation-check-phone-week-aware.html @@ -0,0 +1,672 @@ + + + + + +Check Phone learns to look backward, and forgives shaky GPS + + + + +

Check Phone learns to look backward, and forgives shaky GPS

+

+ c277325 · 12 files · +583 / −78 · 2026-07-18 +

+ + + +

1. Background — what Check Phone actually checks

+ +

+ screen-locker locks the screen until a weekly workout + quota is met. The status window has a voluntary "Check Phone" button + (screen_locker/_status_view_verify.py, + PhoneCheckMixin) that, on click, pulls real data off your + Android phone over ADB and — if it verifies — logs it and applies the + reward. It never trusts a typed claim; it always pulls the actual file + first. Two sources feed it: +

+ +
+
+
+
StrongLifts
+ Pulls workout_result.json off the phone. + _verify_phone_workout in + _phone_verification.py. +
+
+
RunnerUp
+ Pulls the real .tcx file from + /sdcard/Documents/RunnerUp/. + _verify_runnerup_workout in + _runnerup_verification.py. +
+
+
+ +

+ Both of those functions were written to answer one specific question: + did you work out today? They match on today's date only — + StrongLifts by comparing the JSON's date field, RunnerUp by + grepping filenames for today's date string. That's the right question for + the locked screen, which decides whether to unlock right now. But + the voluntary "Check Phone" button reused those same today-only functions, + even though what it's really trying to answer is broader: is this + week's quota met? +

+ +

+ The quota itself, from _weekly_check.py, follows an + anti-gaming rule established in the previous change to this repo: each + verified workout (StrongLifts or RunnerUp) + counts individually, however many happen on one day, because they're + machine-checked and can't be faked. A manual + self-report counts at most once per day. The weekly minimum is 4. +

+ +

+ A week-aware scanner for RunnerUp already existed — + _scan_and_fill_week_runnerup walks every day from Monday to + today, and for each one still ADB-pulls and validates the real TCX file + before writing anything. It was wired into the automatic login check and + the --status CLI, but never into the "Check Phone" button. + Nothing equivalent existed for StrongLifts at all. +

+ +

2. Intuition — a same-day blind spot, and a shaky GPS trace

+ +

+ Here's what actually happened. Wednesday's status window connected to the + phone and clicked Check Phone. The phone had a real RunnerUp run from + yesterday — a 4.9 km run recorded the evening before, + after the button's earlier check that day had already failed. The result: +

+ +
+ Phone check (not_verified): StrongLifts: Workout JSON is from + 2026-07-17, not today · RunnerUp: No RunnerUp activity found for today +
+ +

+ Both messages are literally true and both are useless. There is a + real, verifiable run — the button just never looked at any date but today. +

+ +
+
+
+
Before
+ StrongLifts today? no →
+ RunnerUp today? no →
+ give up +
+
+
After
+ StrongLifts today? no →
+ RunnerUp today? no →
+ scan Mon…today for anything unlogged +
+
+
+ +

+ The week-scan fallback reuses the existing RunnerUp scanner and a new, + symmetric StrongLifts one — both still pull and validate the real file via + ADB, they just stop requiring the date to be today and instead + accept anything from Monday through today that isn't logged yet. +

+ +

The second problem the first one uncovered

+ +

+ Testing this against the real phone surfaced a second issue: that 4.9 km + run should count, but MIN_RUN_DISTANCE_KM is 5.0. GPS traces + measure distance by summing straight-line hops between sampled points, so + tight turns, tree cover, and urban canyons make the recorded distance + consistently shorter than the run actually was. A real 5 km run can + legitimately log as 4.9. So a 5% tolerance was added — applied to distance + only, since duration comes from the device clock and has no such noise: +

+ +
+
+
+
recorded
+ 4.90 km +
+
×1.05 →
+
+
tolerant distance
+ 5.145 km +
+
≥ 5.0 →
+
+
verified
+ counts +
+
+
+ +

+ Play with the whole chain below — it mirrors + _verify_phone_then_runnerup's real order: StrongLifts today, + then RunnerUp today, then the week-scan fallback, using the same 5% + distance rule at every RunnerUp step. +

+ +
+
+ +
+
+ + + +
+
+ + + +
+ + +
+
+
+ + +
+
+
+ +

3. Code — the change, by concern

+ +

3.1 Splitting the date-check out of StrongLifts validation

+ +

+ _validate_json_data used to do two jobs at once: check the + date, then check the exercises/duration/succeeded content. The week-scan + needs the second job without the first, so it was split: +

+ +
+ def _validate_json_content(self, data: dict) -> tuple[str, str]:
++     """...no date check..."""
++     if not data.get("exercises"):
++         return "no_exercises", "No exercises found in the workout JSON"
++     ...
+
+  def _validate_json_data(self, data: dict) -> tuple[str, str]:
+      - today = time.strftime("%Y-%m-%d")
+      - if data.get("date") != today: return "stale", ...
+      - if not data.get("exercises"): ...
++     today = time.strftime("%Y-%m-%d")
++     if data.get("date") != today:
++         return "stale", f"Workout JSON is from {data.get('date')}, not today"
++     return self._validate_json_content(data)
+ +

+ _validate_json_data — the live, today-only check used by the + locked screen's unlock chain — keeps behaving exactly as before. It's now + just a thin date gate in front of the shared content check. +

+ +

3.2 A week-scan backfill for StrongLifts (new — nothing existed before)

+ +

+ RunnerUp's _scan_and_fill_week_runnerup can find multiple + unlogged days because RunnerUp writes one .tcx file per + activity. StrongLifts only has one workout_result.json + snapshot on the phone at a time, so its equivalent can only ever backfill + one day per call — that's not a limitation of the fix, it's what the data + actually allows: +

+ +
+ def _try_fill_stronglifts_for_week(self, log_file: Path) -> int:
++     if not self._has_adb_device():
++         return 0
++     data = self._pull_workout_app_json()
++     date_str = data.get("date") if data else None
++     if not date_str:
++         return 0
++     today = datetime.now(tz=timezone.utc).astimezone().date()
++     week_start = today - timedelta(days=today.weekday())
++     entry_date = date.fromisoformat(date_str)   # ValueError -> 0, logged
++     if not week_start <= entry_date <= today:
++         return 0
++     status, msg = self._validate_json_content(data)   # no date gate
++     if status != "verified":
++         return 0
++     ...
++     if write_signed_entry(log_file, date_str, workout_data).appended:
++         return 1
++     return 0
+ +

+ write_signed_entry dedups by workout_id (here + "phone_verified:{date}"), so re-scanning an already-filled day + is a safe no-op — calling this twice in a row correctly returns + 1 then 0. +

+ +

3.3 Wiring both week-scans into the button

+ +

+ The old return type was a bare 4-tuple, which had no room for "nothing + verified today, but something got backfilled." A small frozen dataclass + replaces it, and a new helper runs both scanners and applies whatever + bonus is earned — reusing the exact prev/new-count math the login + auto-check and --status already use, specifically not + routing through _apply_workout_credit, which always files + under today by design and would mis-date a backfilled entry: +

+ +
+ @dataclass(frozen=True)
++ class PhoneCheckOutcome:
++     credited_type: str | None
++     status: str
++     phone_message: str
++     runnerup_message: str | None
++     week_fill_message: str | None = None
+
++ def _backfill_week_and_apply_bonus(verifier) -> str | None:
++     prev_count = count_weekly_workouts(verifier.log_file)
++     filled = verifier._scan_and_fill_week_runnerup(verifier.log_file)
++     filled += verifier._try_fill_stronglifts_for_week(verifier.log_file)
++     if not filled:
++         return None
++     new_count = count_weekly_workouts(verifier.log_file)
++     bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count))
++     if bonus > 0:
++         verifier._adjust_shutdown_time_by(bonus)
++     return f"Auto-filled {filled} workout(s) from earlier this week." + (
++         f" +{bonus}h shutdown time." if bonus > 0 else ""
++     )
+ +

+ _verify_phone_then_runnerup calls this only after both + today-checks fail, and _on_phone_check_result branches on it: + a verified-today result still goes through _apply_workout_credit + as before; a week_fill_message is shown as-is, because the + backfill step already wrote the entry and applied its own bonus — running + credit again there would double it. +

+ +

3.4 The RunnerUp GPS tolerance

+ +
  distance_km = data["distance_m"] / 1000
++ if distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) < MIN_RUN_DISTANCE_KM:
+-     if distance_km < MIN_RUN_DISTANCE_KM:
+      msg = f"Run was {distance_km:.1f} km — need {MIN_RUN_DISTANCE_KM:.0f}+ km"
+      return "too_short", msg
++ if distance_km < MIN_RUN_DISTANCE_KM:
++     _logger.info("...within %.0f%% GPS tolerance — accepted.", ...)
+ +

+ RUNNERUP_DISTANCE_TOLERANCE = 0.05 lives in + _constants.py next to the minimum it adjusts. The log line on + a tolerance-assisted pass exists so a below-threshold-but-accepted run is + still visible in the logs, never a silent pass — the entry itself still + stores the true, un-inflated distance. +

+ +

3.5 The CLI bug this surfaced (unrelated file, same root cause)

+ +

+ Verifying the fix against the real phone produced a week with two + workouts on one day for the first time. That's when + _status.py's --status CLI printed + "Need 2 more to reach the minimum (4)" for a week that was + actually 4/4 — the GUI, which uses count_weekly_workouts + directly, showed the correct number. +

+ +
- before_count = 0
+- for i in range(7):
+-     ...
+-     if _print_day_line(d, ...):
+-         before_count += 1          # capped at 1 per day, regardless of
+-                                     # how many workouts that day held
++ for i in range(7):
++     ...
++     _print_day_line(d, ...)        # display only now
++ before_count = count_weekly_workouts(log_file)   # the real anti-gaming count
+ +

+ before_count had been derived from _print_day_line's + boolean return — one checkmark per day, regardless of how many workouts + that day held. It happened to agree with the real count for years, because + until this change no day ever legitimately held two verified + entries. The fix makes the CLI compute the same weekly total the GUI + already used, so the two views can no longer disagree. +

+ +

4. Quiz

+

+ Five questions on the reasoning. Score: 0 / 0 +

+
+ + + + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index 91648f2..13f70c5 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -3,7 +3,7 @@ -explanations — change explanations +screen-locker — change explanations