screen-locker/screen_locker/_status_sections.py
Krzysztof kuhy Rudnicki ba3a8a293d 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
2026-11-01 20:04:14 +01:00

215 lines
7.9 KiB
Python

"""The individual rendered sections of the status window.
Split out of :mod:`screen_locker.status_view` to keep every file under the
250-line cap. Composed into ``StatusWindow`` there, alongside the other view
mixins, so the class's public surface is unchanged.
Every method here is display-only: it reads the snapshot it is handed and
paints widgets. Nothing in this module writes to disk or reaches the network.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from screen_locker._earned import hhmm
from screen_locker._sync_status import format_sync_line, gather_sync_status
if TYPE_CHECKING:
import tkinter as tk
from screen_locker._compliance_state import LockExplanation
from screen_locker._status_data import (
DayStatus,
ManualWorkoutBudgetStatus,
ShutdownProjection,
WeeklySummary,
)
class StatusSectionsMixin:
"""Renders each section of the status window from a snapshot."""
def _section_today(self, parent: tk.Widget, day: DayStatus) -> None:
"""Render today's outcome."""
del parent
mark = "✓" if day.counted else ("😷" if day.is_sick_day else "—")
entry_str = ", ".join(day.entry_types) or (
"sick day" if day.is_sick_day else "no entry yet"
)
self._text(
f"{mark} Today ({day.label}): {entry_str}",
role="body",
color=self._colors.palette.success
if day.counted
else self._colors.palette.warning,
)
if day.source:
# Secondary provenance note on an already-shown entry -- a
# deliberately caption-sized exception to the 16px floor.
self._text(
day.source, role="caption", color=self._colors.palette.muted, pad="xs"
)
def _section_week(self, parent: tk.Widget, week: WeeklySummary) -> None:
"""Render this ISO week's per-day breakdown and totals."""
del parent
self._label(
f"This Week: {week.counted_count}/{week.minimum}", role="body", pad="sm"
)
for day in week.days:
mark = "✓" if day.counted else ("😷" if day.is_sick_day else "·")
entry_str = ", ".join(day.entry_types) or (
"sick day" if day.is_sick_day else "no entry"
)
self._text(
f"{mark} {day.label}: {entry_str}",
role="body",
color=self._colors.palette.muted,
pad="xs",
)
if week.remaining > 0:
self._text(
f"Need {week.remaining} more this week.",
role="body",
color=self._colors.palette.warning,
)
elif week.extra > 0:
self._text(
f"{week.extra} above the weekly minimum!",
role="body",
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:
"""Render why the lock did/didn't fire today, plus its evaluation trace."""
del parent
self._label("Why the lock did/didn't fire", role="body", pad="sm")
self._text(
expl.reason,
role="body",
color=self._colors.palette.danger
if expl.fired
else self._colors.palette.success,
)
if expl.auto_upgrade.would_attempt:
self._text(
f"Pending auto-upgrade: {expl.auto_upgrade.reason}",
role="label",
color=self._colors.palette.warning,
)
def _section_manual_workout_budget(
self, parent: tk.Widget, manual: ManualWorkoutBudgetStatus
) -> None:
"""Render rolling manual-workout budget usage."""
del parent
self._label("Manual Workout Budget", role="body", pad="sm")
self._text(
f"{manual.used_7d}/{manual.budget_7d} week · "
f"{manual.used_30d}/{manual.budget_30d} month",
role="body",
color=self._colors.palette.danger
if manual.exhausted
else self._colors.palette.muted,
)
def _section_sync_backend(self, parent: tk.Widget) -> None:
"""Render which backend this machine syncs through.
Read from local state only (see :mod:`screen_locker._sync_status`), so
opening or refreshing the window never makes a network call. Coloured
by ``healthy`` rather than by backend: "configured but never pushed"
looks fine everywhere else and produces no data, which is exactly the
state worth surfacing here.
"""
del parent
status = gather_sync_status()
self._label("Sync backend", role="body", pad="sm")
self._text(
format_sync_line(status),
role="body",
color=self._colors.palette.muted
if status.healthy
else self._colors.palette.danger,
)
def _section_shutdown(
self, parent: tk.Widget, shutdown: ShutdownProjection
) -> None:
"""Render tonight's live config, rest-of-week, and next-week preview."""
del parent
self._label("Shutdown Time", role="body", pad="sm")
if shutdown.tonight is not None:
mon_wed, thu_sun, _morning = shutdown.tonight
self._text(
f"Live config — Mon-Wed {hhmm(mon_wed)}, Thu-Sun {hhmm(thu_sun)}",
role="body",
)
else:
self._text(
"Live shutdown config unavailable.",
role="body",
color=self._colors.palette.warning,
)
# Rest-of-week/next-week/explanation are speculative annotations, not
# the section's primary content -- a deliberate caption-sized
# exception to the 16px floor, all sharing one muted tone instead of
# three unrelated ad hoc grays.
rest_line = ", ".join(
f"{d.label} {hhmm(d.minutes)}" for d in shutdown.rest_of_week
)
self._text(
f"Rest of week: {rest_line}",
role="caption",
color=self._colors.palette.muted,
)
next_line = ", ".join(
f"{d.label} {hhmm(d.minutes)}" for d in shutdown.next_week_preview
)
self._text(
f"Next week (speculative): {next_line}",
role="caption",
color=self._colors.palette.muted,
)
self._text(
shutdown.explanation, role="caption", color=self._colors.palette.muted
)