mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
A real 1h57m workout on 2026-08-24 synced to Firebase and the locker still locked the screen. The trigger was auth: screen_locker was the only app with no cached firebase_auth.json, so it fell back to a stale shared password and got HTTP 400 every run. The defect was what it did next — an unreadable primary source was reported as "no workout" rather than "could not check", which is the same conflation that took 2026-06-12. Recover the session by borrowing a live refresh token from a sibling app, track degraded sources as state instead of a log line, and say so on the lock screen: name the backend, the error, and how stale the data is, so a sync fault reads as a sync fault. Also fix two bugs found while finishing the recovery: restore_and_bonus.py banked `existing + bonus` with no idempotence guard while finish_2026_08_24.sh advertised "safe to re-run" — a second run silently granted the compensation hours again. Guard on a durable `shutdown_bonus_granted_for` list; the bonus map alone cannot answer the question, since a week's total mixes earned streak hours with corrections. _extra_benefits._save_state rebuilt state from four hardcoded keys, so the next weekly rollover would have dropped that guard and re-armed the double-grant. Merge onto loaded state instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5
120 lines
4.5 KiB
Python
120 lines
4.5 KiB
Python
"""Why a workout source is untrustworthy, in words a human can act on.
|
|
|
|
Two different failures produced the same blank screen on 2026-08-24:
|
|
|
|
* Firebase raised -- the credential no longer authenticated. Loud, but only
|
|
in the journal.
|
|
* The GitHub mirror did *not* raise. It answered promptly and completely with
|
|
data that had stopped changing on 2026-08-15, because the phone had quietly
|
|
stopped writing to it. Nothing in the code compared a record's age to now,
|
|
so a backend that had been dead for nine days looked identical to a backend
|
|
reporting "no workouts yet today".
|
|
|
|
The second is the dangerous one: an exception at least leaves a traceback. A
|
|
silent staleness leaves a confident, wrong answer. Both are surfaced here as
|
|
the same kind of finding, so the lock screen can explain itself instead of
|
|
accusing the user of skipping a workout they actually did.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from screen_locker._sync_client import degraded_sources
|
|
|
|
# A phone that has synced nothing for this long is not "quiet", it is broken.
|
|
# Two days spans a rest day plus a travel day without crying wolf, while still
|
|
# catching the nine-day silence that made 2026-08-24 unexplainable.
|
|
STALE_AFTER = timedelta(days=2)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceFinding:
|
|
"""One reason a workout source cannot be trusted right now."""
|
|
|
|
name: str
|
|
headline: str
|
|
detail: str
|
|
|
|
|
|
def describe_staleness(
|
|
name: str, newest: datetime | None, *, now: datetime | None = None
|
|
) -> SourceFinding | None:
|
|
"""Return a finding when *name*'s newest record is too old, else None.
|
|
|
|
Args:
|
|
name: The backend, as the user would name it ("GitHub mirror").
|
|
newest: Timestamp of the most recent record it returned, or None when
|
|
it returned nothing at all.
|
|
now: Injected for tests.
|
|
"""
|
|
instant = now if now is not None else datetime.now(tz=timezone.utc)
|
|
if newest is None:
|
|
return SourceFinding(
|
|
name=name,
|
|
headline=f"{name} returned no workouts at all",
|
|
detail=(
|
|
f"{name} answered, but with an empty log. Either nothing has "
|
|
"ever synced from your phone, or this backend is not the one "
|
|
"your phone writes to."
|
|
),
|
|
)
|
|
age = instant - newest.astimezone(timezone.utc)
|
|
if age < STALE_AFTER:
|
|
return None
|
|
return SourceFinding(
|
|
name=name,
|
|
headline=f"{name} has not changed in {age.days} days",
|
|
detail=(
|
|
f"The newest workout {name} knows about is from "
|
|
f"{newest.date().isoformat()} — {age.days} days ago. It is "
|
|
"answering, so nothing looks broken, but your phone has stopped "
|
|
"writing to it. A workout logged today would not appear here."
|
|
),
|
|
)
|
|
|
|
|
|
def collect_source_findings(
|
|
*, newest_synced: datetime | None = None, now: datetime | None = None
|
|
) -> list[SourceFinding]:
|
|
"""Gather every reason the workout sources may be lying right now.
|
|
|
|
Combines the two failure shapes: backends that raised (recorded by
|
|
``_sync_client``) and backends that answered with stale data.
|
|
"""
|
|
findings = [
|
|
SourceFinding(
|
|
name=source.name,
|
|
headline=f"{source.name} could not be read at all",
|
|
detail=(
|
|
f"{source.name} refused the connection: {source.reason}. Your "
|
|
"phone syncs workouts there, so anything logged today is "
|
|
"invisible to this machine until that is fixed."
|
|
),
|
|
)
|
|
for source in degraded_sources()
|
|
]
|
|
stale = describe_staleness("the workout sync", newest_synced, now=now)
|
|
if stale is not None:
|
|
findings.append(stale)
|
|
return findings
|
|
|
|
|
|
def explain_findings(findings: list[SourceFinding]) -> str:
|
|
"""Render findings as the block of text shown on the lock screen.
|
|
|
|
Deliberately plain prose: this is read by someone who just finished a
|
|
two-hour workout and is being told it does not count. "Sync error" is
|
|
not an explanation; naming the backend, the age and the consequence is.
|
|
"""
|
|
if not findings:
|
|
return ""
|
|
lines = ["Why this may be wrong:"]
|
|
lines.extend(f"• {finding.headline}\n {finding.detail}" for finding in findings)
|
|
lines.append(
|
|
"If you did work out, this is a sync fault, not a missed workout — "
|
|
"use TRY AGAIN, or log it once sync is restored."
|
|
)
|
|
return "\n".join(lines)
|