mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
`sync_now()` -- what workout-sync.timer runs every 15 minutes -- only ever ingested MANUAL records. The session reader `pull_synced_workout()` was reachable only from `_verify_phone_workout`, i.e. the interactive lock/status UI, so a finished session earned nothing until a lock screen happened to open. That was survivable while the phone was the only device recording sessions: the same lock screen that demanded the workout also verified it. The Linux build finishes sessions with no lock screen involved at all, so the credit would simply never arrive. `pull_all_session_records()` is the session counterpart to the existing `pull_all_manual_records()` -- `pull_synced_workout` returns only the single newest session for the live unlock check, which cannot backfill a week. New type `pc_workout_verified` rather than reusing `phone_verified`: it is machine-checked in exactly the same way (same WorkoutSession payload, same set/rep and duration validation), but there is no phone involved, and writing "phone_verified" into an HMAC-signed log would make the log lie about its own provenance. Ingestion re-derives the verdict instead of trusting the record's `succeeded` flag -- the record arrives over the network and this is the thing handing out unlock credit. Every rejection logs at WARNING with an actionable reason, per the repo's no-silent-failures rule. One real leak fixed along the way: formatting the duration with `.0f` rounded 34m59s up to "35 min", which is precisely the hidden accept bar -- the message would have told the user the real threshold while rejecting them at it. It now truncates, so the advertised 40 stays the only number shown. Verified against live data: ingests the 4 real synced sessions, and a second run ingests 0 (the write chokepoint dedups by workout_id), so the repeating timer cannot double-credit. Suite green at 100% branch coverage, 1046 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
129 lines
5.4 KiB
Python
129 lines
5.4 KiB
Python
"""Manual-workout sync and RunnerUp auto-fill for the screen locker.
|
|
|
|
Split out of ``screen_lock.py`` to keep every file under the 250-line cap.
|
|
These methods form one cohesive unit: they publish this PC's workouts, ingest
|
|
records other devices produced, and apply the same shutdown/debt credit a
|
|
live-logged workout earns.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from screen_locker._constants import EXTRA_BENEFITS_FILE
|
|
from screen_locker._extra_benefits import weekly_shutdown_bonus_hours
|
|
from screen_locker._manual_push import push_pc_workouts
|
|
from screen_locker._manual_sync import ingest_manual_records
|
|
from screen_locker._session_sync import ingest_session_records
|
|
from screen_locker._weekly_check import (
|
|
WEEKLY_WORKOUT_MINIMUM,
|
|
count_weekly_workouts,
|
|
)
|
|
from screen_locker._workout_sync import (
|
|
pull_all_manual_records,
|
|
pull_all_session_records,
|
|
)
|
|
|
|
__all__ = ["SyncMixin"]
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SyncMixin:
|
|
"""Publish, ingest and credit manual workouts synced between devices."""
|
|
|
|
def sync_now(self) -> None:
|
|
"""Run one sync pass: publish this PC's workouts, ingest everyone's.
|
|
|
|
The public entry point behind ``--sync-only``, which the
|
|
``workout-sync.timer`` unit calls. Sync used to happen only inside the
|
|
locker's startup path, so a workout finished after login stayed
|
|
invisible until the next login.
|
|
|
|
Also re-runs the RunnerUp TCX backfill here, not just at login: the
|
|
login-time scan gets exactly one shot, and if the phone isn't
|
|
adb-visible at that instant (e.g. USB debugging not yet
|
|
authorized), a same-day run stays uncredited until the next login.
|
|
Repeating it every 15 minutes closes that gap.
|
|
"""
|
|
self._ingest_synced_manual_workouts()
|
|
self._ingest_synced_sessions()
|
|
self._auto_fill_week_runnerup_bonus()
|
|
|
|
def _ingest_synced_manual_workouts(self) -> None:
|
|
"""Sync manual workouts: publish this PC's, ingest everyone else's.
|
|
|
|
Each newly-ingested record earns the identical shutdown/debt reward a
|
|
live-logged workout would (see
|
|
``WorkoutCreditMixin._apply_credit_for_written_entry``), regardless of
|
|
whether it's dated today or back-dated — there's only one current
|
|
shutdown config, so a back-dated sync still pushes it.
|
|
"""
|
|
push_pc_workouts(self.log_file)
|
|
ingested = ingest_manual_records(
|
|
self.log_file,
|
|
pull_all_manual_records(),
|
|
on_ingested=self._credit_ingested_manual_workout,
|
|
)
|
|
for record_id in ingested:
|
|
_logger.info("Ingested synced manual workout: %s", record_id)
|
|
self.workout_data = {}
|
|
|
|
def _ingest_synced_sessions(self) -> None:
|
|
"""Ingest StrongLifts sessions other devices (or this PC) finished.
|
|
|
|
The desktop build of workout_app writes a session to its own device
|
|
log and nothing else; without this the record would only ever be seen
|
|
by the interactive verification flow, so a workout finished on the PC
|
|
earned nothing until a lock screen happened to open.
|
|
|
|
Each newly-ingested session earns the identical reward a live-logged
|
|
workout would, and re-running is a no-op (the write chokepoint dedups
|
|
by workout_id).
|
|
"""
|
|
ingested = ingest_session_records(
|
|
self.log_file,
|
|
pull_all_session_records(),
|
|
on_ingested=self._credit_ingested_manual_workout,
|
|
)
|
|
for record_id in ingested:
|
|
_logger.info("Ingested synced StrongLifts session: %s", record_id)
|
|
self.workout_data = {}
|
|
|
|
def _credit_ingested_manual_workout(
|
|
self, entry: dict[str, str], prior_entries: list[dict]
|
|
) -> None:
|
|
"""Apply the live-workout reward to a manual workout ingested via sync."""
|
|
self.workout_data = entry
|
|
credit = self._apply_credit_for_written_entry(prior_entries)
|
|
if credit.shutdown_adjusted:
|
|
_logger.info(
|
|
"Synced manual workout pushed shutdown time +2h: %s",
|
|
entry.get("source", ""),
|
|
)
|
|
elif credit.extra_bonus_delta:
|
|
_logger.info(
|
|
"Synced manual workout added +%dh shutdown time: %s",
|
|
credit.extra_bonus_delta,
|
|
entry.get("source", ""),
|
|
)
|
|
|
|
def _auto_fill_week_runnerup_bonus(self) -> None:
|
|
"""Auto-fill missed RunnerUp workouts and award any earned bonus."""
|
|
prev_count = count_weekly_workouts(self.log_file)
|
|
n_filled = self._scan_and_fill_week_runnerup(self.log_file)
|
|
if not n_filled:
|
|
return
|
|
new_count = count_weekly_workouts(self.log_file)
|
|
_logger.info("Auto-filled %d RunnerUp workout(s) from TCX exports.", n_filled)
|
|
# Award +1h for each newly auto-filled workout above the minimum.
|
|
bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count))
|
|
if bonus > 0 and self._adjust_shutdown_time_by(bonus):
|
|
_logger.info("Auto-fill extra bonus: +%dh shutdown time.", bonus)
|
|
|
|
def _apply_weekly_shutdown_bonus(self) -> None:
|
|
"""Layer this week's earned shutdown bonus back on top of the fresh base."""
|
|
bonus = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE)
|
|
if bonus > 0 and self._adjust_shutdown_time_by(bonus):
|
|
_logger.info("Weekly bonus: +%dh shutdown time this week.", bonus)
|