From ba3a8a293d896a8d17fb2db17e96e29f4b83ee9d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 1 Nov 2026 20:00:03 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU --- prompts/TODO-dopamine-ux-04-screen-locker.md | 211 ------------------ screen_locker/_extra_benefits.py | 23 +- screen_locker/_status_data.py | 2 + screen_locker/_status_sections.py | 35 +++ screen_locker/_status_types.py | 1 + screen_locker/status_view.py | 1 + screen_locker/tests/test_extra_benefits.py | 5 +- .../tests/test_extra_benefits_part2.py | 20 +- screen_locker/tests/test_mcp.py | 7 + screen_locker/tests/test_status_data_part2.py | 19 ++ .../tests/test_status_streak_section.py | 63 ++++++ 11 files changed, 164 insertions(+), 223 deletions(-) delete mode 100644 prompts/TODO-dopamine-ux-04-screen-locker.md create mode 100644 screen_locker/tests/test_status_streak_section.py diff --git a/prompts/TODO-dopamine-ux-04-screen-locker.md b/prompts/TODO-dopamine-ux-04-screen-locker.md deleted file mode 100644 index fec1b73..0000000 --- a/prompts/TODO-dopamine-ux-04-screen-locker.md +++ /dev/null @@ -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 | | | - 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 ` 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 diff --git a/screen_locker/_extra_benefits.py b/screen_locker/_extra_benefits.py index c3d66e0..79b66a1 100644 --- a/screen_locker/_extra_benefits.py +++ b/screen_locker/_extra_benefits.py @@ -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", [])) diff --git a/screen_locker/_status_data.py b/screen_locker/_status_data.py index d610534..82810aa 100644 --- a/screen_locker/_status_data.py +++ b/screen_locker/_status_data.py @@ -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, diff --git a/screen_locker/_status_sections.py b/screen_locker/_status_sections.py index 776a781..64006cc 100644 --- a/screen_locker/_status_sections.py +++ b/screen_locker/_status_sections.py @@ -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: diff --git a/screen_locker/_status_types.py b/screen_locker/_status_types.py index 75853d6..ff856da 100644 --- a/screen_locker/_status_types.py +++ b/screen_locker/_status_types.py @@ -86,3 +86,4 @@ class StatusSnapshot: lock_explanation: LockExplanation manual_workout_budget: ManualWorkoutBudgetStatus generated_at: str + week_rewards: tuple[str, ...] = () diff --git a/screen_locker/status_view.py b/screen_locker/status_view.py index eef7f03..c2e2851 100644 --- a/screen_locker/status_view.py +++ b/screen_locker/status_view.py @@ -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( diff --git a/screen_locker/tests/test_extra_benefits.py b/screen_locker/tests/test_extra_benefits.py index d076095..65d3dcd 100644 --- a/screen_locker/tests/test_extra_benefits.py +++ b/screen_locker/tests/test_extra_benefits.py @@ -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 diff --git a/screen_locker/tests/test_extra_benefits_part2.py b/screen_locker/tests/test_extra_benefits_part2.py index d9bde96..936e388 100644 --- a/screen_locker/tests/test_extra_benefits_part2.py +++ b/screen_locker/tests/test_extra_benefits_part2.py @@ -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: diff --git a/screen_locker/tests/test_mcp.py b/screen_locker/tests/test_mcp.py index 7c62f3e..4611407 100644 --- a/screen_locker/tests/test_mcp.py +++ b/screen_locker/tests/test_mcp.py @@ -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, diff --git a/screen_locker/tests/test_status_data_part2.py b/screen_locker/tests/test_status_data_part2.py index dcc4f0c..bde7ace 100644 --- a/screen_locker/tests/test_status_data_part2.py +++ b/screen_locker/tests/test_status_data_part2.py @@ -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( diff --git a/screen_locker/tests/test_status_streak_section.py b/screen_locker/tests/test_status_streak_section.py new file mode 100644 index 0000000..d0db7e2 --- /dev/null +++ b/screen_locker/tests/test_status_streak_section.py @@ -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)