diff --git a/docs/explanations/2026-07-13-explanation-flutter-coverage-to-100.html b/docs/explanations/2026-07-13-explanation-flutter-coverage-to-100.html deleted file mode 100644 index 0a5c9ea..0000000 --- a/docs/explanations/2026-07-13-explanation-flutter-coverage-to-100.html +++ /dev/null @@ -1,464 +0,0 @@ - - -
- - -How a stuck push gate got unblocked — and the Flutter widget-test techniques that made the hard 267 lines of chart, calendar and live-workout UI testable.
- - - -The repo contains a Flutter phone app, workout_app, that tracks strength workouts and syncs them to a PC. To keep quality high, the project installs a pre-push gate: whenever a commit touches Dart code, pushing runs two checks locally before the push is allowed to leave your machine.
lib/ line, and every file must appear in the reportThat second check is strict in two ways. First, the bar is 100% — not 90%, not "the lines you changed". Second, a helper script (check_flutter_coverage.sh) also fails if any lib/**/*.dart file is missing from the coverage report, so you can't quietly earn 100% by never importing a file into a test.
The catch: this gate had never once passed for a Dart commit. It was added while the app was only ~83% covered, so ten commits — an entire phone-to-PC sync feature — sat finished but unpushable, stuck behind 316 uncovered lines. This change closes that gap and ships all of it.
- -| File | Before | After | Nature of the gap |
|---|---|---|---|
| storage_service.dart | 139/165 | 163/163 | migrations, restore, edge branches |
| home_screen.dart | 117/121 | 121/121 | post-navigation reloads |
| settings_screen.dart | 243/262 | 262/262 | device-flow branches, steppers |
| history_screen.dart | 167/308 | 307/307 | whole exercise drill-down + chart |
| workout_screen.dart | 144/270 | 269/269 | live-workout breaks & finish |
The two screens at the bottom — the history charts/calendar and the live-workout timer UI — held 267 of the 316 lines and were the genuinely hard part. Testing them ran head-first into a subtlety about how time works inside a Flutter widget test. That's the idea worth understanding, so it gets its own section.
- -A normal testWidgets test does not run on real time. Flutter wraps it in a fake clock (FakeAsync): timers only advance when you explicitly say tester.pump(someDuration). This is usually wonderful — you can "wait" 3 minutes for a rest timer in a microsecond, deterministically.
But the app's real database, sqflite, schedules work on real timers and background isolates. Under the fake clock, those never get a chance to finish. So the moment a tapped widget kicks off a database write, you get a landmine:
- -So the rule that unlocked the live-workout screen is: any tap that triggers a database write or starts a Timer.periodic must be dispatched inside tester.runAsync(...), which runs that code on the genuine event loop where the work can actually finish. The assertion afterward happens on the fake clock again, so you still get deterministic expects.
Two corollaries fell out of this while writing the tests:
-_confirmFinish in the same zone the first tap fired in, and that continuation does six sqflite writes. So the whole finish flow — open dialog, confirm, save — has to live in a single runAsync block, not just the confirm tap.tester.longPress fights runAsync. It does its own internal fake-clock pumps to time the press, which don't advance inside runAsync. The fix is a manual gesture: startGesture, hold for a real 700 ms, then release — a real-time long-press.Pick where the tapped code runs and what it does, then dispatch the tap. Watch whether the background work completes or leaks a pending timer.
- -Zone discipline makes taps survive, but many lines were uncovered simply because the widget was never built. Flutter's ListView lazily builds only what's near the viewport, so the history screen's session tiles and the "DUMBBELL LUNGE" section label — which sit below an 800×600 test fold — never existed in the tree, and their lines stayed cold. The fix is to scrollUntilVisible / ensureVisible them first: scrolling builds them, which both renders and covers them.
coverage:ignore is forA few lines genuinely can't run on a Linux CI host, so they're marked coverage:ignore with a stated reason rather than faked. The bar for an ignore is "no reachable test path exists," not "this was annoying to test":
Vibration.vibrate() — hasVibrator() returns false on Linux (no Platform.isAndroid/isIOS), so the body never runs there.getDatabasesPath()) — tests always inject an in-memory or temp-file path instead.null-state branch that seeding makes impossible, and a history _load branch only reachable via a path that never calls _load.The diff is 792 insertions across ten files, but it splits cleanly into two buckets: a lot of test code, and a handful of tiny production edits that make code reachable from tests. Grouped by concern:
- -To test the "server not started" placeholder, a private widget had to become public — private widgets can't be constructed from a test file:
--class _ServerAddressTile extends StatelessWidget {
-- const _ServerAddressTile({required this.addresses});
-+/// ...Public so the empty-address fallback can be exercised in a widget test.
-+class ServerAddressTile extends StatelessWidget {
-+ const ServerAddressTile({required this.addresses, super.key});
-
-And to test the schema migration, resetForTesting gained an optional path so a test can hand it a real file DB (in-memory DBs can't be reopened at a new version, which is exactly what an onUpgrade migration needs):
- static void resetForTesting() {
-- _testDbPath = ':memory:';
-+ static void resetForTesting({String dbPath = ':memory:'}) {
-+ _testDbPath = dbPath;
-
-Four small blocks are marked unreachable-on-CI, each with a comment explaining why — for example the haptics call:
- if (await Vibration.hasVibrator()) {
-+ // Android/iOS-only: hasVibrator() returns false on the Linux test host
-+ // (no Platform.isAndroid/isIOS), so this body never runs there.
-+ // coverage:ignore-start
- unawaited(Vibration.vibrate(duration: 800));
-+ // coverage:ignore-end
- }
-
-The bulk of the change. A representative live-workout test: a tap starts a rest break, and the tap is dispatched through a helper that keeps it on the real event loop so the sqflite write and periodic timer complete cleanly.
-Future<void> _tapReal(WidgetTester tester, Finder f) async {
- await tester.runAsync(() async {
- await tester.tap(f);
- await Future<void>.delayed(const Duration(milliseconds: 100));
- });
- await tester.pump();
-}
-
-testWidgets('tapping a set starts a break which Skip cancels', (tester) async {
- await _pump(tester, _wrap());
- await _tapReal(tester, find.byType(RepCircle).first);
- expect(find.byType(BreakBanner), findsOneWidget); // break started
- await _tapReal(tester, find.text('Skip'));
- expect(find.byType(BreakBanner), findsNothing); // Skip cancelled it
-});
-
-And the schema-migration unit test that the new dbPath seam unlocked — build a v1 database on disk, open it through StorageService (which runs at v3), and assert the migration ran:
final oldDb = await databaseFactory.openDatabase(dbFile,
- options: OpenDatabaseOptions(version: 1, onCreate: /* v1 schema */));
-await oldDb.close();
-
-StorageService.resetForTesting(dbPath: dbFile); // real file, not :memory:
-await StorageService.init(); // triggers onUpgrade 1 → 3
-
-expect(await _svc.getNextWorkoutType(), 'A'); // settings table now exists
-expect((await _svc.getExerciseState(name))!.successThreshold, 3); // column added
-
-The gate also requires a spotless flutter analyze, and it exits non-zero on even an info. The last commit documents the newly-public field, reshapes the ignore comment so it isn't an over-long line, and reflows one pre-existing 81-char comment. Small, but the push doesn't go through without it.
Five questions on the reasoning behind the change. Click an answer to reveal whether it's right and why.
- -
- feat/check-phone-runnerup-and-tray · 3 commits · 62 files ·
- +2719 / −777 · 2026-07-17
-
- screen-locker is a discipline tool. A systemd user service runs
- python -m screen_locker.screen_lock --production at login and again
- from a timer at 08:30/09:05. If you haven't earned your workout, it throws a
- fullscreen Tk window over everything until you do. If you have, it calls
- sys.exit(0) and you never see it. That's why "the locker isn't
- running" is usually the healthy state, not a fault.
-
- It decides using screen_locker/log.json — a gitignored,
- HMAC-signed file. Each entry is signed with a key shared across screen-locker,
- diet_guard and wake_alarm, so the log can't be quietly hand-edited to fake a
- workout. Two things read it constantly: has_weekly_minimum()
- (fire the lock or not?) and the reward path (how late may the PC stay on
- tonight?).
-
- A workout gets in three ways. phone_verified — a
- StrongLifts session pulled off the phone. runnerup_verified
- — a real run, parsed from the actual TCX file that RunnerUp writes to
- /sdcard/Documents/RunnerUp/ and pulled over ADB. And
- manual_workout — a self-reported evidence form
- (squash, table tennis), rate-limited by a budget because nothing verifies it.
- That distinction — machine-checked versus self-reported — is the hinge the
- whole change turns on.
-
- There's also a companion Flutter app on the phone, and a shared GitHub repo
- (kuhyx/syncs) holding a small CRDT log per device, so the two
- machines can agree on what happened.
-
- log.json was keyed by date, one entry per day.
- The writer said so in as many words, and did exactly that:
-
# _log_mixin.py — before -"""...The log is day-keyed: one entry per date.""" -logs[date] = entry # a second workout that day OVERWRITES the first
- So the file physically could not hold two workouts on one day. Everything
- downstream inherited that: count_weekly_workouts iterated the date
- keys, so "4 workouts a week" and "4 days a week" were the same
- sentence. Nobody had to choose between them, because the data model had
- already chosen.
-
- The trigger was mundane. On Monday 13 July, kuhy played squash and went - for a 9.8 km run. He logged the squash by hand. The run — a real, verified, - TCX-backed run — had nowhere to go. The day already had its slot. -
- -- Letting a day hold many workouts sounds harmless until you notice what the - lock is for. If every entry counted, you could type four - manual entries on a Monday morning, satisfy "4 - workouts this week", and never be locked again until Sunday. The whole tool - would be an honour system with extra steps. -
- -- So the rule isn't "count workouts". It's count what can't be - faked: -
- -- Play with it. Add workouts and watch the count and the lock: -
- -
- Once a day can hold many workouts, "is this one already here?" stops being
- free. Every entry now carries a workout_id — deliberately the
- same string the sync layer uses as its record id, so a workout logged
- on the PC and the same workout arriving from the phone collapse to one row
- instead of two.
-
manual:2026-07-13T14:00
- runnerup_verified:2026-07-13
- startTime.toIso8601String()
-
- While fixing the above, a second thing surfaced. The PC was supposed to
- publish its workouts so the phone could see them. It never had —
- screen-locker-sync/ in the repo contained a single
- .gitkeep. Two independent reasons:
-
push_pc_manuals() opened with
- if token is None: return — a bare return, no log line. The PAT
- had been saved to the wrong app's config directory. The code knew and said
- nothing.
- manual_sync_log.json,
- written only at log-time. So anything recorded before that feature existed —
- like the squash — could never publish, no matter how many times it ran.
-
- The fix for (2) also fixes a category error: derive the push from
- log.json itself. The log is the truth; the sync is a
- projection of it. Then history publishes automatically, forever, with no
- second place to fall out of date.
-
- Notice the shape of the failure. Not a crash — a return []. During
- the debugging session, pull_all_manual_records() returned
- 0 and it was impossible to tell whether the repo
- was empty or the call had failed, because the token guard was silent and real
- errors logged at INFO. The answer only came from bypassing the
- function and hitting the API by hand.
-
scripts/check_silent_failures.py, a
- pre-commit hook. Every except must re-raise or log at
- warning+. It found 27 violations across 16 modules
- on its first run — and flagged its own source file too.
-
- Worth knowing why a custom AST rule was needed at all: ruff already runs with
- select = ["ALL"], so S110 (except: pass)
- was already enforced — and the codebase had none. The lethal pattern
- wasn't pass. It was except: log.info(...); return [],
- which looks handled and no ruff rule catches.
-
- A new dependency-free module is the single place that reads the file. Old and - new shapes both load, so a half-migrated file — or a rolled-back writer — is - never a crash. Crucially, wrapping a legacy dict in a list leaves the - signed bytes untouched, so every existing HMAC still verifies with - zero re-signing. -
- -+ # screen_locker/_log_io.py (new)
-+ def load_workout_log(log_file) -> dict[str, list[dict]]:
-+ normalized = {}
-+ for date, value in read_raw_log(log_file).items():
-+ if isinstance(value, list):
-+ normalized[date] = [e for e in value if isinstance(e, dict)]
-+ elif isinstance(value, dict):
-+ normalized[date] = [value] # legacy: wrap, don't rewrite
-+ return normalized
- The overwrite becomes an append behind a single chokepoint that returns a
- result instead of None — so the caller can tell a genuinely new
- workout from a duplicate, and scale the reward accordingly.
-
- logs[date] = entry # overwrite -+ workout_id = _derive_workout_id(date, workout_data) -+ if workout_id is not None and any( -+ _entry_workout_id(date, e) == workout_id for e in entries -+ ): -+ return RecordResult(appended=False, prior_entries=prior) -+ entries.append(entry) -+ return RecordResult(appended=True, prior_entries=prior)
e.get("workout_id") directly. Legacy entries predate that field,
- so theirs is None — un-matchable. The moment the PC started
- publishing history, the 08:30 timer ingested the squash back and appended a
- second copy of it. It had been dismissed in the plan as a "known
- minor, accepted" risk; it fired within the hour, on real data.
- _entry_workout_id() now derives an existing entry's id
- when it has none, so a legacy row matches its own synced twin.
-- The anti-gaming asymmetry from section 2, in the one function that both the - lock and the banked bonus consult: -
- -for date_str, entries in load_workout_log(log_file).items():
- ...
- has_manual = False
- for entry in entries:
- wtype = entry.get("workout_data", {}).get("type", "")
- if wtype in VERIFIED_WORKOUT_TYPES:
- count += 1 # each verified workout counts
- elif wtype == MANUAL_WORKOUT_TYPE:
- has_manual = True
- if has_manual:
- count += 1 # all of a day's manuals count as one
- _apply_workout_credit now scales to the day: the first counted
- workout earns the base +2h; an additional same-day
- verified one earns +1h; an additional manual earns
- nothing. The old "+1h when weekly_count > 4" gate was removed —
- weekly volume is the banked bonus's job, and paying for it twice was the bug
- hiding in plain sight. The credit fires only if the write actually appended
- (id guard) and the entry is dated today (date guard) — so a
- back-dated backfill raises the count but never pushes tonight's shutdown.
-
- The tests caught a real defect here: the unlock screen still labelled the +1h
- #{weekly_count - 4}, which under the new rule can render
- negative.
-
- Record id = the workout's own workout_id. HLC = derived from the
- entry's own timestamp — which makes a re-push byte-identical, so the
- repo never churns. That determinism is what lets the push run on every startup
- without turning the sync repo into a commit firehose.
-
+ hlc = Hlc.new_tick(_PC_DEVICE_ID, wall_time_ms=_entry_wall_ms(entry, date))
-+ log[record_id] = Record(id=record_id, fields={_PAYLOAD_FIELD: (payload, hlc)})- And nothing about it is silent any more: -
- -+ @dataclass(frozen=True)
-+ class PushResult:
-+ pushed: bool
-+ record_count: int
-+ reason: str # always a sentence a human can act on
- The phone kept a Keystore token and mirrored it to
- /sdcard — but load() only consulted the mirror when
- the Keystore was empty, never when its token was
- present-but-rejected. A stale entry could shadow a good backup forever
- while the UI insisted all was well. Now a 401 triggers one retry from the
- mirror before anyone is asked to re-authorize.
-
- The same pattern then closed a sharper hole. active_session — which
- set you're on, and the reps done — lived only in app-private SQLite. A
- pm clear wiped it mid-workout, and backup.json
- didn't help because it mirrors only completed history. The state had to
- be re-entered by hand. The persistence granularity was already right (it saves
- on every set tap); only the location was wrong.
-
active_sessionsync.token
- active_session.jsonsync_tokenbackup.json
-
- On launch, an empty table re-seeds itself from the mirror. Set 4 of the
- Romanian Deadlift, reps [8,8,8,8], survives an uninstall.
-
+ # scripts/check_silent_failures.py — pre-commit hook `no-silent-failures`
-+ def _handler_is_loud(handler) -> bool:
-+ """True if the handler re-raises or logs loudly about the failure."""
-+ return any(
-+ isinstance(node, ast.Raise) or _is_loud_log_call(node)
-+ for node in ast.walk(handler)
-+ )
- There's deliberately no escape hatch, because noqa is banned
- repo-wide. Two ironies for the record: the checker's own docstring contained
- the literal string # noqa and tripped the noqa-blocker, and
- _log_io.py — written for this very change — violated the new rule.
- Both were invisible while untracked, since git ls-files hides
- untracked files from the hooks, and only surfaced once committed.
-
- Five questions on the reasoning. Score: 0 / 0 -
- - - - - 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 deleted file mode 100644 index 2f82422..0000000 --- a/docs/explanations/2026-07-18-explanation-check-phone-week-aware.html +++ /dev/null @@ -1,686 +0,0 @@ - - - - - -
- 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.
-
MIN_RUN_DISTANCE_KM = 3.5 (inclusive, still with the 5% GPS
- tolerance described above) or at
- MIN_RUN_DURATION_MINUTES = 40 (strict, no tolerance). The
- 30-minute floor every run had to clear is gone, so a fast 5 km now
- passes too. The interactive demo and quiz below still use the old 5.0 km
- AND-rule.
-
- 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 deleted file mode 100644 index 13f70c5..0000000 --- a/docs/explanations/index.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - -3 explanations · interactive walkthroughs of notable changes, newest first.
- - -