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 @@ + + +
+ + +
+ c277325 · 12 files · +583 / −78 · 2026-07-18
+
+ 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:
+
workout_result.json off the phone.
+ _verify_phone_workout in
+ _phone_verification.py.
+ .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.
+
+ 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. +
+ ++ 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. +
+ +
+ 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:
+
+ 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.
+
+ _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.
+
+ 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.
+
+ 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.
+
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.
+
+ 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.
+
+ 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 @@ -