Show the workout streak in the status window

Adds StatusSectionsMixin._section_streak, called from
StatusWindow.render: the stored consecutive 5+-workout-week streak and
the reward message the week transition produced, display only. A streak
of 0 is stated plainly, never padded; a "Streak reset" message is
warning-coloured, not green. get_status already returned the streak
(it is asdict(gather_status())); StatusSnapshot gains week_rewards.

The week-transition strings used to go to _logger.info only.
process_week_transition now also keeps them under a week_rewards key in
the existing extra_benefits_state.json (no new file), read back for the
current ISO week by week_rewards(). Their "early-bird extended to 09:00"
clause is dropped: early_bird_extended is read only by display code, no
lock or window decision uses it (early bird is wake-alarm's signed
exemption since 2026-09-20), so it is not shown in the window either.

Per kuhy: no daily-streak state, no shutdown time for streaks, no
freezes or grace days; no enforcement logic touched.

Red first: new section/state/snapshot tests failed, then passed. On a
scratch copy of the real state the window shows "7-week streak" and
"6 workouts in 2026-W43! 7-week streak", no early-bird line.

Removes prompts/TODO-dopamine-ux-04-screen-locker.md (done).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 20:00:03 +01:00
parent aa2776a45e
commit ba3a8a293d
11 changed files with 164 additions and 223 deletions

View File

@ -1,211 +0,0 @@
> **This file is a ready-to-use prompt.** Open this repo and say
> "do dopamine-ux-04-screen-locker". It is self-contained -- it needs no context from
> any other session.
>
> Canonical copy: `~/src/utils/dopamine-ux/04-screen-locker.md` (source of truth; this is a
> distributed copy). Running order and cross-prompt rules: `~/src/utils/dopamine-ux/00-INDEX.md`.
> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate
> as of that date; **anchor on the symbol names, not the line numbers.**
## ⛔ WHEN YOU FINISH THIS PROMPT
Do **both**, in the same session that completes the work:
1. **Delete this file** and commit that deletion together with the
implementation:
```bash
git rm prompts/dopamine-ux-04-screen-locker.md
```
Do not leave a finished prompt lying in the repo -- a stale prompt is
indistinguishable from a pending one, and the next session will re-run it.
2. **Log completion in the canonical index**, in `~/src/utils` (a *different* repo,
so it needs its own commit):
```bash
cd ~/src/utils
# append to the "Completion log" table in dopamine-ux/00-INDEX.md:
# | 04-screen-locker.md | DONE <YYYY-MM-DD> | <impl commit sha> | <one-line note> |
git add dopamine-ux/00-INDEX.md
git commit -m "dopamine-ux: mark 04-screen-locker.md done"
git push
```
If you finish only part of the prompt, do **neither** -- instead edit this file
to strike out what is done and state what remains, so the next session starts
from the true state. Partial completion must never be recorded as DONE.
---
Surface the workout streak that screen-locker already tracks
## findings (3, ranked by value over effort)
1. **`snapshot.streak` is populated and no renderer reads it** — the cheapest
win in the whole project. Zero new state, zero conftest change, zero deps.
2. **Week-transition reward strings are discarded into `_logger.info`** —
fully-formed celebratory sentences that never reach a user.
3. **The success moment exists only on the lock screen**, for 1.5s. The status
window — the surface visited *voluntarily* — shows only enforcement.
## what
screen-locker computes a real workout streak and celebratory reward text, then
throws almost all of it away. Verified 2026-08-16:
- `StatusSnapshot` (`screen_locker/_status_types.py` ~:88-101) carries populated
`streak` and `early_bird_extended` fields, filled by
`_status_data.py` ~:210-214. **Grep confirms no renderer reads them.**
- `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at
~:130-139) produces fully-formed celebratory sentences — and
`_startup_checks.py` ~:57-58 sends them to `_logger.info` and nowhere else.
- The one success moment that exists is on the **lock screen**:
`_unlock_view.py` `unlock_screen()` (~:25) paints "Great job! 💪" and
`🔥 {streak}-week streak`, for `_UNLOCK_DELAY_MS = 1500` before routing away.
So the surface the user visits **voluntarily** — the status window, opened from
the tray icon — shows enforcement and budget depletion, and no evidence of
success at all. This is the cheapest high-value change in the whole project:
the data already exists, is already true, and needs no new state.
## where
Repo: `~/src/screen-locker`.
Primary:
- `screen_locker/_status_sections.py` — the section renderers used by the status
window (`_section_*` functions). **177 lines; has headroom.** Add
`_section_streak` here.
- `screen_locker/status_view.py` — `StatusWindow`, `render()`. Call the new
section from here. ⚠️ **246 lines — only 4 left under the 250 cap.** Expect to
extract something, or add the call in a way that costs ~1 line.
- `screen_locker/_status_types.py` — `StatusSnapshot` (~:88-101), the fields you
are rendering. Read-only for this task.
- `screen_locker/_extra_benefits.py` (179 lines) — `process_week_transition()`
and its reward strings; `current_streak()`, `has_extended_early_bird()`.
- `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are
currently discarded into a log call.
- `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing
success moment, if you extend it.
## must
- **Render the already-populated fields.** Add `_section_streak` to
`_status_sections.py` showing the weekly streak and the extended-early-bird
state, and call it from `status_view.py`'s `render()`.
Zero new state, zero conftest change, zero new dependency.
- **Stop discarding the reward strings.** `process_week_transition()` returns
human-readable celebratory sentences that currently only reach the log. Surface
them where the user will actually see them — the status window is the obvious
home; a milestone is worth more than a silent counter increment.
- Match the existing positive-status styling rather than inventing one:
`_status_sections.py` ~:73-78 already renders `"{n} above the weekly minimum!"`
in success green, and uses `✓` marks at ~:36 and ~:57.
- Keep the streak **true**. It counts real ISO weeks with ≥5 counted workouts
(`_weekly_check.py`: `WEEKLY_WORKOUT_MINIMUM = 5`, `COUNTED_WORKOUT_TYPES =
{phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or
"encourage" the number.
- must not: **make any reward reduce enforcement.** This is recorded in the
repo as a deliberate decision:
> `_startup_checks.py` — "there is no banked 'skip a workout' credit —
> that mechanic works against the goal of maximizing weekly workouts, so it was
> removed."
Every reward here is **display only**. Do not reintroduce a
banked skip, a streak freeze, a grace day, or any mechanic that lowers the
floor. A dopamine loop that lets you skip the workout defeats the app.
- must not: **grant shutdown time for a streak, a week or a milestone.** The
weekly banked shutdown bonus (and the 4-week `_MILESTONE_INTERVAL` +1h) was
removed on purpose (2026-10-10): every shutdown minute comes from the
`earned_time` ladder and stops at its daily ceiling. Rewards here are display
only — no "+Nh" weekly reward, no bonus-hours field, no new shutdown source.
- must not: add **sound** to any enforcement or lock path. screen-locker is a
self-restriction tool; a chime on lock is punishment audio, and a chime on
unlock trains the wrong association. This repo stays silent. (Sound is
opt-out-on elsewhere in the fleet; here it is simply absent.)
- must not: add a write tool to the MCP server. `_mcp.py` ~:14-26 states it is
read-only by design. Adding `streak` to the existing `get_status` output is
fine; a new action tool violates a stated invariant.
- must not: add `# noqa` (banned repo-wide) or a silent `except`
(`scripts/check_silent_failures.py` — every handler re-raises or logs at
warning or above).
- must not: create a new on-disk state file unless genuinely unavoidable. If you
do, you **must** add it to `_ISOLATED_STATE` in
`screen_locker/tests/conftest.py` (~:134), listing *every* module that binds
the path at import time — a missed binding lets tests write to kuhy's real
state. Read the comment at ~:131-133 and the fixture docstring at ~:170-175.
- optional: a daily streak alongside the weekly one. Note this **would** be new
state (the existing streak is consecutive-*weeks*), so it triggers the conftest
requirement above. diet-guard's consecutive-*day* streak
(`~/src/diet-guard/diet_guard/_daystatus.py` ~:144) is the precedent to mirror.
## done
1. Opening the status window (tray icon → click, or `screen-locker-status`) shows
the current streak and early-bird state — with the real values
from `extra_benefits_state.json`, not placeholders.
2. A week-transition milestone reaches the UI instead of only `_logger.info`.
3. `cd ~/src/screen-locker && python -m pytest` passes.
4. `pre-commit run --files <changed files>` is clean — including the 250-line cap
(`scripts/check_file_length.py`) and the silent-failure check.
5. `git diff` touches no enforcement logic: no change to lock decisions, weekly
minimums, or shutdown floors.
## verify
Desktop — this is a desktop Python app.
```
cd ~/src/screen-locker
python -m screen_locker --status # CLI: confirm the values it prints
screen-locker-status # the Tk window: confirm they render
```
Paste both outputs. The CLI already prints the streak and early-bird state
(`_status.py`), so it is your ground truth: the window must show the **same numbers**.
If they disagree, the renderer is wrong, not the data.
Current real state for comparison: `screen_locker/extra_benefits_state.json` and
`screen_locker/log.json` (12 days of entries, 2026-07-12 → 2026-08-15).
## read first
- `screen_locker/_status_sections.py` — every existing `_section_*`. Match their
signature and style; note the success-green precedent at ~:73-78.
- `screen_locker/_status_types.py` ~:88-101 — the three fields you are rendering.
- `screen_locker/_extra_benefits.py` — especially the module docstring ~:5-8
(the enforcement invariant) and the reward strings ~:130-139.
- `screen_locker/_unlock_view.py` — the existing celebration, for tone.
- `screen_locker/status_view.py` — check its line count **before** editing.
- `~/src/screen-locker/CLAUDE.md` — repo rules (no silent failures, ruff `select=ALL`).
## context you would otherwise rediscover
- **`log.json` is the single source of truth**, written only through
`_log_mixin.py` `write_signed_entry()` (~:82), HMAC-signed via
`gatelock.log_integrity`, deduped on `workout_id`. The Flutter app converges on
it via `_manual_push.py`. There is no second history — a streak computed from
it is authoritative.
- All persistence is **JSON files, no sqlite**; paths declared in
`screen_locker/_constants.py`. The streak store is
`screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`,
`last_processed_iso_week`, `extended_early_bird_iso_weeks`; a legacy
`weekly_shutdown_bonus_hours` map is ignored and dropped on the next write).
- The threshold lives in `_extra_benefits.py`: `_BONUS_THRESHOLD = 5` (workouts
per week, for the streak and the early-bird extension). There is no milestone
constant any more.
- **There is no config system** — no argparse, no env vars, no config file. Every
tunable is a module-level constant. An opt-out toggle has no existing home;
for a self-restriction tool, requiring a code edit to disable is arguably
correct, so prefer a constant in `_constants.py` over new state.
- `~/gatelock` is **not** a repo — it is `~/src/utils/gatelock/gatelock/`, consumed as
a pinned git dep, and it holds no workout state.
- Manual workouts were **not** removed: `_manual_workout.py` implements a
rate-limited, evidence-gated subsystem (budget 2 per 7 days, 10 per 30).
- Three UI surfaces exist: the Tk status window (`status_view.py`), a GTK tray
icon at `~/.config/i3/scripts/screen_locker_tray.py` (outside the repo, shells
out to the CLI, refreshes every 60s), and the lock screen itself. No i3blocks
integration exists despite `format_summary_line` being described as one.
REMOVE ME AFTER FINISH

View File

@ -2,7 +2,10 @@
Tracks:
- Consecutive weeks with 5+ workouts (streak counter).
- ISO weeks in which the early-bird window is extended to 09:00.
- ISO weeks in which the early-bird window is extended to 09:00. Display-only
bookkeeping: nothing reads it to decide a lock (early bird is wake-alarm's
signed exemption since 2026-09-20).
- The week-transition reward messages, for the status window.
There is no weekly shutdown-time bonus (dropped entirely 2026-10-10, hours
already banked for 2026-W41 included): every shutdown minute comes from the
@ -108,8 +111,7 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
if current_week_str not in eb_weeks:
eb_weeks.append(current_week_str)
rewards.append(
f"{prev_week_count} workouts in {prev_week_str}! "
f"{streak}-week streak, early-bird extended to 09:00 this week"
f"{prev_week_count} workouts in {prev_week_str}! {streak}-week streak"
)
else:
if streak > 0:
@ -128,6 +130,9 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
"consecutive_5plus_weeks": streak,
"last_processed_iso_week": current_week_str,
"extended_early_bird_iso_weeks": eb_weeks,
# Kept for the status window (display only): the strings above
# used to go to the log and nowhere else.
"week_rewards": {"iso_week": current_week_str, "messages": rewards},
}
)
_save_state(state_file, updated)
@ -147,3 +152,15 @@ def has_extended_early_bird(state_file: Path, *, today: datetime | None = None)
"extended_early_bird_iso_weeks", []
)
return current_week_str in eb_weeks
def week_rewards(state_file: Path, *, today: datetime | None = None) -> tuple[str, ...]:
"""The reward messages this ISO week's transition produced, for display.
Empty once the week has passed, or when nothing was stored.
"""
now = today if today is not None else datetime.now(tz=UTC).astimezone()
stored = _load_state(state_file).get("week_rewards", {})
if not isinstance(stored, dict) or stored.get("iso_week") != _current_iso_week(now):
return ()
return tuple(str(m) for m in stored.get("messages", []))

View File

@ -48,6 +48,7 @@ from screen_locker._constants import (
from screen_locker._extra_benefits import (
current_streak,
has_extended_early_bird,
week_rewards,
)
from screen_locker._log_io import load_workout_log
from screen_locker._morning_session import has_workout_skip_today
@ -177,6 +178,7 @@ def gather_status(
week=week,
streak=current_streak(extra_benefits_file),
early_bird_extended=early_bird_extended,
week_rewards=week_rewards(extra_benefits_file, today=today_local),
shutdown=_shutdown_projection(
shutdown_config_file=shutdown_config_file,
today_local=today_local,

View File

@ -81,6 +81,41 @@ class StatusSectionsMixin:
color=self._colors.palette.success,
)
def _section_streak(
self, parent: tk.Widget, streak: int, week_rewards: tuple[str, ...] = ()
) -> None:
"""Render the consecutive 5+-workout-week streak and this week's reward.
Display only, and exactly the stored values (``extra_benefits_state``):
a streak of 0 is said plainly, never padded or rounded up. Nothing here
grants time or relaxes a lock. The early-bird extension is not shown:
it no longer changes anything (wake-alarm owns early bird).
"""
del parent
self._label("Workout streak", role="body", pad="sm")
if streak > 0:
self._text(
f"🔥 {streak}-week streak (5+ workouts a week)",
role="body",
color=self._colors.palette.success,
)
else:
self._text(
"No streak yet: 5+ workouts in a week starts one.",
role="body",
color=self._colors.palette.muted,
)
for message in week_rewards:
# A reset is news, not a celebration: it must not be painted green.
lost = message.startswith("Streak reset")
self._text(
message,
role="body",
color=self._colors.palette.warning
if lost
else self._colors.palette.success,
)
def _section_lock_explanation(
self, parent: tk.Widget, expl: LockExplanation
) -> None:

View File

@ -86,3 +86,4 @@ class StatusSnapshot:
lock_explanation: LockExplanation
manual_workout_budget: ManualWorkoutBudgetStatus
generated_at: str
week_rewards: tuple[str, ...] = ()

View File

@ -115,6 +115,7 @@ class StatusWindow(
self._label("Workout Status", role="title", pad="md")
self._section_today(self.container, snapshot.today)
self._section_week(self.container, snapshot.week)
self._section_streak(self.container, snapshot.streak, snapshot.week_rewards)
self._section_lock_explanation(self.container, snapshot.lock_explanation)
self._section_temperature(self.container)
self._section_manual_workout_budget(

View File

@ -106,9 +106,8 @@ class TestProcessWeekTransition:
assert len(rewards) == 1
assert rewards[0].startswith("6 workouts in ")
assert rewards[0].endswith(
"! 1-week streak, early-bird extended to 09:00 this week"
)
assert rewards[0].endswith("! 1-week streak")
assert "09:00" not in rewards[0]
assert not any("shutdown bonus" in r for r in rewards)
state = json.loads(f.read_text())
assert state["consecutive_5plus_weeks"] == 1

View File

@ -10,6 +10,7 @@ from unittest.mock import patch
from screen_locker._extra_benefits import (
has_extended_early_bird,
process_week_transition,
week_rewards,
)
if TYPE_CHECKING:
@ -49,12 +50,7 @@ class TestBonusDroppedAtW42:
):
rewards = process_week_transition(tmp_path / "log.json", f)
assert rewards == [
(
"5 workouts in 2026-W41! 4-week streak, "
"early-bird extended to 09:00 this week"
)
]
assert rewards == ["5 workouts in 2026-W41! 4-week streak"]
state = json.loads(f.read_text())
# Sibling keys (the compensation guard) survive; only the dead map goes.
assert state == {
@ -62,9 +58,21 @@ class TestBonusDroppedAtW42:
"last_processed_iso_week": "2026-W42",
"extended_early_bird_iso_weeks": ["2026-W41", "2026-W42"],
"shutdown_bonus_granted_for": ["2026-08-24"],
"week_rewards": {"iso_week": "2026-W42", "messages": rewards},
}
w42 = datetime(2026, 10, 12, 12, 0, tzinfo=UTC)
assert has_extended_early_bird(f, today=w42) is True
# The message the transition produced is kept for the status window,
# for the week it was earned in only.
assert week_rewards(f, today=w42) == tuple(rewards)
assert week_rewards(f, today=datetime(2026, 10, 19, 12, 0, tzinfo=UTC)) == ()
def test_no_stored_rewards_reads_as_empty(self, tmp_path: Path) -> None:
"""Missing file, or a state without the key, has nothing to show."""
assert week_rewards(tmp_path / "absent.json") == ()
f = tmp_path / "state.json"
f.write_text(json.dumps({"consecutive_5plus_weeks": 2}))
assert week_rewards(f) == ()
class TestHasExtendedEarlyBird:

View File

@ -87,6 +87,13 @@ class TestReadTools:
# asdict output must be pure JSON (no datetime/dataclass leaks).
json.dumps(out)
def test_get_status_carries_the_streak_fields(self) -> None:
"""The streak and early-bird state are part of get_status (read-only)."""
with patch.object(_mcp, "gather_status", return_value=_snapshot()):
out = _mcp.get_status()
assert out["streak"] == 1
assert out["early_bird_extended"] is False
def test_get_summary_combines_line_and_state(self) -> None:
with (
patch.object(_mcp, "gather_status") as gs,

View File

@ -121,6 +121,25 @@ class TestGatherStatus:
assert snap.streak == 2
assert snap.early_bird_extended is True
assert snap.week_rewards == ()
def test_this_weeks_transition_message_is_surfaced(self, tmp_path: Path) -> None:
"""The reward text a week transition produced reaches the snapshot."""
files = _files(tmp_path)
msg = "5 workouts in 2023-W52! 3-week streak"
files["extra_benefits_file"].write_text(
json.dumps(
{
"consecutive_5plus_weeks": 3,
"week_rewards": {"iso_week": "2024-W01", "messages": [msg]},
}
)
)
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=False
):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.week_rewards == (msg,)
assert snap.shutdown.rest_of_week[0].minutes == 20 * 60 # base, no bonus
def test_next_week_preview_is_speculative_rest_of_week_is_not(

View File

@ -0,0 +1,63 @@
"""The status window shows the workout streak and early-bird state it tracks."""
# pylint: disable=protected-access
from __future__ import annotations
from typing import TYPE_CHECKING
from screen_locker.tests._status_view_helpers import (
_make_window,
_snapshot,
_texts,
)
if TYPE_CHECKING:
from unittest.mock import MagicMock
def _fg_of(mock_tk: MagicMock, needle: str) -> list[object]:
return [
c.kwargs.get("fg")
for c in mock_tk.Label.call_args_list
if needle in c.kwargs.get("text", "")
]
class TestSectionStreak:
"""Tests for StatusWindow._section_streak (display only)."""
def test_shows_the_real_week_count_in_success_color(
self, mock_tk: MagicMock
) -> None:
window = _make_window(mock_tk, _snapshot(streak=3))
assert any("3-week streak" in t for t in _texts(mock_tk))
assert window._colors.palette.success in _fg_of(mock_tk, "3-week streak")
def test_zero_streak_is_stated_plainly_not_padded(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot(streak=0))
texts = _texts(mock_tk)
assert any("No streak" in t for t in texts)
assert not any("-week streak" in t for t in texts)
assert window._colors.palette.muted in _fg_of(mock_tk, "No streak")
def test_early_bird_is_never_rendered(self, mock_tk: MagicMock) -> None:
"""The 09:00 early-bird extension is gone (wake-alarm owns it)."""
_make_window(mock_tk, _snapshot(streak=2, early_bird_extended=True))
assert not any("arly-bird" in t for t in _texts(mock_tk))
def test_week_reward_reaches_the_window_in_success_color(
self, mock_tk: MagicMock
) -> None:
msg = "5 workouts in 2026-W41! 4-week streak"
window = _make_window(mock_tk, _snapshot(streak=4, week_rewards=(msg,)))
assert msg in _texts(mock_tk)
assert window._colors.palette.success in _fg_of(mock_tk, msg)
def test_no_reward_text_when_the_week_has_none(self, mock_tk: MagicMock) -> None:
_make_window(mock_tk, _snapshot(streak=4))
assert not any("workouts in 20" in t for t in _texts(mock_tk))
def test_a_streak_reset_is_not_painted_as_a_win(self, mock_tk: MagicMock) -> None:
msg = "Streak reset (was 3 weeks of 5+ workouts)"
window = _make_window(mock_tk, _snapshot(streak=0, week_rewards=(msg,)))
assert window._colors.palette.warning in _fg_of(mock_tk, msg)