The early-bird window is the wake-alarm carrot, not a clock
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

05:00-08:30 (09:00 with the extended reward) locked nobody but did hold the
workout lock off on a clock that knew nothing about whether the user was
up, next to the morning-session rung that did. EarlyBirdMixin._is_early_bird_time
is now that rung's verdict: open before the alarm, during the session, and
until 11:00 once it completed in time -- exactly what wake-alarm signs. The
wake_alarm_skip rung banks the pending marker so the carrot's end still tries
a phone/RunnerUp workout before locking; early-bird-workout-check.timer moves
to 09:30:30 and 11:00:30, the two possible ends. The consumer's boot-retry
window starts at 05:00 with the refresher.

Also: the sick screen lists four past justifications, one line each -- six
full-length ones pushed it 7 px past 1366x768, which the screens-fit gate
only sees with a real history on disk. Takes @types/node 26.6.2, eslint 10.11.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTdfXjbGYYrY7Quxcfz6mg
This commit is contained in:
Krzysztof Rudnicki 2026-09-20 08:39:44 +02:00
parent 53bbdda419
commit 9ab7c09c79
24 changed files with 207 additions and 402 deletions

View File

@ -43,7 +43,16 @@ own `submitted_at`, flat +1h/day, and *fail closed* -- an unreadable ledger or
key earns nothing. leetcode-guard itself stays read-only; it never writes
the config.
## The morning session is the carrot
## The morning session is the carrot — and the early-bird window is it
`EarlyBirdMixin._is_early_bird_time` no longer reads a clock: it is open
exactly while wake-alarm's signed file grants an exemption (before the
alarm, during the session, until 11:00 once it completed in time). The
05:00–08:30 window and the extended-to-09:00 reward locked someone who got
up at 07:00 and were a second mechanism next to the one that knows whether
the user is up (2026-09-20). The pending marker is banked by the
`wake_alarm_skip` rung; `early-bird-workout-check.timer` (09:30:30, 11:00:30)
re-checks just after the carrot's two possible ends.
The `wake_alarm_skip` rung reads wake-alarm's HMAC-signed
`~/.local/state/wake_alarm/morning_session.json` (`_morning_session.py`), not

View File

@ -10,14 +10,17 @@ Description=Re-check workout after early bird grace period expires (08:30 normal
# After=graphical-session.target, which is where that ordering belongs.
[Timer]
# Fires at 08:30 for the normal early-bird window (5:00–8:30).
# Also fires at 09:05 to catch the extended window (5:00–9:00) earned by 5+ workout weeks.
# Both runs are idempotent: the service handles "already logged" and "window still active".
OnCalendar=*-*-* 08:30:00
OnCalendar=*-*-* 09:05:00
# The early-bird window is the wake-alarm morning-session carrot since
# 2026-09-20 (screen_locker/_early_bird.py): it ends at the signed
# exempt_until -- 11:00 after a completed morning, min(start+90 min, 09:30)
# after an aborted one, alarm+15 min when no session started. These runs land
# just after the two boundaries the 5-minute tick could miss by up to five
# minutes; every run is idempotent ("already logged", "carrot still in force").
OnCalendar=*-*-* 09:30:30
OnCalendar=*-*-* 11:00:30
Unit=workout-locker.service
# Persistent=true: a machine asleep or off at 08:30 would otherwise skip the
# re-check entirely, leaving the early-bird marker banked and the day
# Persistent=true: a machine asleep or off at a boundary would otherwise skip
# the re-check entirely, leaving the early-bird marker banked and the day
# unenforced. Both runs are idempotent (see above), so a catch-up run is safe.
Persistent=true
AccuracySec=1s

View File

@ -21,7 +21,7 @@ import sys
from screen_locker import _sick_tracker
from screen_locker._decision_log import LockDecision, record_decision
from screen_locker._decision_reasons import reasons_extra
from screen_locker._morning_session import has_workout_skip_today, morning_skip_today
from screen_locker._morning_session import has_workout_skip_today
from screen_locker._weekly_check import has_weekly_minimum, is_relaxed_day
_logger = logging.getLogger(__name__)
@ -109,15 +109,13 @@ class AutoUpgradeMixin(_ReasonsMixin):
# (_compliance_state, where the expired marker is non-terminal) said
# "lock skipped". Fall through to the same ladder as any other morning.
if pending and window_open:
# The ONLY thing that closes this window is
# early-bird-workout-check.timer. When that timer was disabled by an
# ordering cycle (2026-08-04) this branch silently deferred the lock
# every single day, forever.
# The window is the wake-alarm carrot (see EarlyBirdMixin): it
# closes when the signed exempt_until passes, and the 5-minute
# tick plus early-bird-workout-check.timer then re-decide.
_skip(
"early_bird_window_active",
"Early bird window still active — deferring to the 08:30/09:05 "
"re-check timer.",
recheck_by="early-bird-workout-check.timer",
f"Morning-session carrot still in force: {self._morning_skip}.",
recheck_by="workout-locker.timer",
**self._other_conditions("early_bird_window_active"),
)
elif self._is_sick_day_today():
@ -138,22 +136,18 @@ class AutoUpgradeMixin(_ReasonsMixin):
"Workout already logged today.",
**self._other_conditions("workout_logged_today"),
)
elif (skip := morning_skip_today(wait=True)) is not None:
elif window_open:
# Bank the marker so the run that sees the carrot end tries a
# phone/RunnerUp workout before falling through to a lock.
self._save_early_bird_pending()
skip = self._morning_skip
_skip(
"wake_alarm_skip",
f"Morning session earned it: {skip}.",
exempt_until=skip.exempt_until.isoformat(),
outcome=skip.outcome,
f"Morning session earned it: {skip or 'carrot in force'}.",
exempt_until=skip.exempt_until.isoformat() if skip else None,
outcome=skip.outcome if skip else None,
**self._other_conditions("wake_alarm_skip"),
)
elif window_open:
self._save_early_bird_pending()
_skip(
"early_bird_banked",
"Early bird time — banked the pending marker, will re-check "
"at 08:30/09:05.",
recheck_by="early-bird-workout-check.timer",
)
else:
return False
return True

View File

@ -21,11 +21,6 @@ from typing import TYPE_CHECKING
import freedays
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from screen_locker._constants import (
EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE,
EARLY_BIRD_START_HOUR,
)
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
from screen_locker._sick_tracker import is_sick_day as _is_sick_day
@ -141,16 +136,3 @@ def is_sick_day_today(history: SickHistory, *, today: str | None = None) -> bool
the status layer have one predicate module to import from.
"""
return _is_sick_day(history, today=today)
def _early_bird_window_open(*, extended: bool, local_minutes: int) -> bool:
"""Deliberate, independent reimplementation of ``_is_early_bird_time``.
Not shared with ``EarlyBirdMixin._is_early_bird_time`` because too many
existing tests are pinned to its ``_get_local_time_minutes`` patch point.
Kept here as a small, separate, pure re-implementation for the status
layer only — see the module docstring in ``_early_bird.py``.
"""
start = EARLY_BIRD_START_HOUR * 60
end = 9 * 60 if extended else EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
return start <= local_minutes < end

View File

@ -22,7 +22,6 @@ from datetime import UTC, datetime
from typing import TYPE_CHECKING
from screen_locker._compliance_predicates import (
_early_bird_window_open,
has_logged_today,
is_early_bird_pending,
is_relaxed_day_skipped_today,
@ -66,7 +65,6 @@ def explain_lock_decision(
log_file: Path,
early_bird_pending_file: Path,
sick_history: SickHistory,
extended_early_bird: bool,
weekly_minimum_met: bool,
relaxed_day: bool,
wake_skip: bool = False,
@ -82,16 +80,13 @@ def explain_lock_decision(
instant = now if now is not None else datetime.now(tz=UTC)
today_str = local_day(instant)
local_dt = instant.astimezone()
local_minutes = local_dt.hour * 60 + local_dt.minute
scheduled = is_scheduled_skip_today(local_dt.date())
pending = is_early_bird_pending(early_bird_pending_file, today=today_str)
window_open = _early_bird_window_open(
extended=extended_early_bird, local_minutes=local_minutes
)
# The early-bird window IS the wake-alarm carrot (EarlyBirdMixin).
window_open = wake_skip
expired = pending and not window_open
early_bird_open_and_pending = pending and window_open
window_end_label = "09:00" if extended_early_bird else "08:30"
sick_today = is_sick_day_today(sick_history, today=today_str)
logged = has_logged_today(log_file, today=today_str)
relaxed_day_already_skipped = relaxed_day and is_relaxed_day_skipped_today(
@ -192,19 +187,6 @@ def explain_lock_decision(
if result is not None:
return result
result = _check(
"early_bird_time_fresh",
fired=window_open,
reason_true=(
f"Currently inside the early-bird window (05:00-{window_end_label})."
),
reason_false="Not currently inside the early-bird window.",
terminal_reason="Inside the early-bird window — lock skipped, pending marker "
f"would be saved for the {window_end_label} re-check.",
)
if result is not None:
return result
result = _check(
"relaxed_day_already_skipped",
fired=relaxed_day_already_skipped,

View File

@ -31,6 +31,13 @@ SICK_LOCKOUT_MULTIPLIER_PER_RECENT = 2
SICK_JUSTIFICATION_MIN_CHARS = 120
# How many past sick justifications to show on the dialog (read-only).
SICK_HISTORY_REVIEW_COUNT = 10
# The sick screen lists past justifications one per line, at most this many
# and each cut to this many characters: six full-length ones pushed the
# screen 7 px past 1366x768, which the screens-fit gate only sees with a real
# history on disk. The review *count* above is for the budget logic, not the
# screen.
SICK_SCREEN_RECENT_LINES = 4
SICK_JUSTIFICATION_PREVIEW_CHARS = 36
# Forced read-only delay before SUBMIT enables when a commitment was made.
SICK_COMMITMENT_FORCED_READ_SECONDS = 5
# Breaking a commitment counts as this many sick budget days.

View File

@ -4,27 +4,22 @@ The early-bird "still waiting to see if a real workout shows up" flag is a
same-day pending marker, not a workout — it is intentionally kept out of
log.json (which is reserved for real outcomes) and instead lives in
its own self-expiring, HMAC-signed state file, mirroring the pattern used by
``wake_alarm._state`` in the companion wake-alarm service.
``wake_alarm._state`` in the companion wake-alarm service. It is written when
the wake-alarm carrot defers the lock, so that when the carrot ends the next
run tries a phone/RunnerUp workout before locking.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
import logging
from gatelock.log_integrity import compute_entry_hmac
from screen_locker._compliance_state import is_early_bird_pending
from screen_locker._constants import (
EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE,
EARLY_BIRD_PENDING_FILE,
EARLY_BIRD_START_HOUR,
EXTRA_BENEFITS_FILE,
)
from screen_locker._constants import EARLY_BIRD_PENDING_FILE
from screen_locker._day import today_str
from screen_locker._extra_benefits import has_extended_early_bird
from screen_locker._morning_session import MorningSkip, morning_skip_today
_logger = logging.getLogger(__name__)
@ -35,27 +30,27 @@ def _today_str() -> str:
class EarlyBirdMixin:
"""Mixin providing early-bird time window checks and pending-state helpers."""
"""Mixin providing the early-bird window check and pending-state helpers.
def _get_local_time_minutes(self) -> int:
"""Return current local time as minutes from midnight."""
now = datetime.now(tz=UTC).astimezone()
return now.hour * 60 + now.minute
Since 2026-09-20 the window is not a wall clock but the wake-alarm
morning-session carrot: open exactly while wake-alarm's signed
``morning_session.json`` says the morning is being earned (before the
alarm, during the session, and until 11:00 once it completed in time).
The old 05:00-08:30 clock locked someone who got up at 07:00 -- the
opposite of the goal -- and it was a second mechanism next to the one
that actually knows whether the user is up.
"""
_morning_skip: MorningSkip | None = None
def _is_early_bird_time(self) -> bool:
"""Return True if current local time is in the early bird window.
"""Whether the wake-alarm carrot is in force right now.
Normally the window closes at 08:30. When the current ISO week has an
extended early-bird reward (earned by 5+ workouts the prior week) the
window extends to 09:00.
Waits (bounded) for a freshly booted PC's refresher; the verdict is
kept on the instance so the skip detail can quote its ``exempt_until``.
"""
minutes = self._get_local_time_minutes()
start = EARLY_BIRD_START_HOUR * 60
if has_extended_early_bird(EXTRA_BENEFITS_FILE):
end = 9 * 60 # 09:00
else:
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
return start <= minutes < end
self._morning_skip = morning_skip_today(wait=True)
return self._morning_skip is not None
def _is_early_bird_pending(self) -> bool:
"""Check if today has an unresolved early-bird pending marker."""

View File

@ -32,8 +32,8 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__)
# Only inside this local window is a non-today file worth waiting for: the
# refresher runs 07:00-11:00, so outside it "not today's" is simply the truth.
MORNING_WINDOW: tuple[tuple[int, int], tuple[int, int]] = ((7, 0), (11, 0))
# refresher runs 05:00-11:00, so outside it "not today's" is simply the truth.
MORNING_WINDOW: tuple[tuple[int, int], tuple[int, int]] = ((5, 0), (11, 0))
MORNING_RETRY_SECONDS: float = 30.0
MORNING_POLL_SECONDS: float = 5.0

View File

@ -12,6 +12,7 @@ from screen_locker import _sick_tracker
from screen_locker._constants import (
SICK_COMMITMENT_FORCED_READ_SECONDS,
SICK_JUSTIFICATION_MIN_CHARS,
SICK_SCREEN_RECENT_LINES,
)
from screen_locker._sick_commitment import SickCommitmentMixin
from screen_locker._surface_group import FrameGroup, TextGroup
@ -40,7 +41,9 @@ class SickDialogMixin(SickCommitmentMixin):
_sick_tracker.budget_summary(history), color=self._colors.palette.warning
)
recent = _sick_tracker.format_recent_justifications(history)
recent = _sick_tracker.format_recent_justifications(
history, n=SICK_SCREEN_RECENT_LINES
)
if recent:
self._text(
"Recent sick days:",

View File

@ -19,6 +19,7 @@ from screen_locker._constants import (
SICK_HISTORY_FILE,
SICK_HISTORY_REVIEW_COUNT,
SICK_JUSTIFICATION_MIN_CHARS,
SICK_JUSTIFICATION_PREVIEW_CHARS,
)
from screen_locker._sick_budget import (
SickHistory,
@ -235,7 +236,9 @@ def format_recent_justifications(
lines: list[str] = []
for entry in entries:
date_str = entry.get("date", "?")
symptom = entry.get("symptom", "?")
symptom = str(entry.get("symptom", "?"))
if len(symptom) > SICK_JUSTIFICATION_PREVIEW_CHARS:
symptom = symptom[: SICK_JUSTIFICATION_PREVIEW_CHARS - 1] + "…"
severity = entry.get("severity", "?")
lines.append(f"{date_str} sev {severity}/10 — {symptom}")
return "\n".join(lines)

View File

@ -194,7 +194,6 @@ def gather_status(
log_file=log_file,
early_bird_pending_file=early_bird_pending_file,
sick_history=sick_history,
extended_early_bird=early_bird_extended,
weekly_minimum_met=week.counted_count >= WEEKLY_WORKOUT_MINIMUM,
relaxed_day=is_relaxed_day(today=today_local),
wake_skip=has_workout_skip_today(),

View File

@ -28,7 +28,6 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
"_constants.EXTRA_BENEFITS_FILE",
"_startup_checks.EXTRA_BENEFITS_FILE",
"_sync_mixin.EXTRA_BENEFITS_FILE",
"_early_bird.EXTRA_BENEFITS_FILE",
"_status.EXTRA_BENEFITS_FILE",
),
),

View File

@ -38,7 +38,6 @@ class TestExplainLockDecision:
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=False,
)
@ -58,9 +57,9 @@ class TestExplainLockDecision:
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=False,
wake_skip=True,
now=now,
)
assert result.fired is False
@ -80,7 +79,6 @@ class TestExplainLockDecision:
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=False,
now=now,
@ -98,7 +96,6 @@ class TestExplainLockDecision:
result = explain_lock_decision(
**files,
sick_history=SickHistory(sick_days=[_today()]),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=False,
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
@ -115,7 +112,6 @@ class TestExplainLockDecision:
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=False,
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
@ -128,7 +124,6 @@ class TestExplainLockDecision:
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=False,
wake_skip=True,
@ -137,47 +132,25 @@ class TestExplainLockDecision:
assert result.fired is False
assert result.stage == "wake_alarm_skip"
def test_fresh_early_bird_time_skips(self, tmp_path: Path) -> None:
def test_carrot_in_force_skips_as_wake_alarm(self, tmp_path: Path) -> None:
"""Before the alarm, during the session, until 11:00 -- one stage, one reason."""
files = self._files(tmp_path)
now = datetime.now(tz=UTC).astimezone().replace(hour=6, minute=0)
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=False,
now=now,
wake_skip=True,
)
assert result.fired is False
assert result.stage == "early_bird_time_fresh"
assert "08:30" in result.reason
assert "09:00" not in result.reason
def test_fresh_early_bird_time_names_09_00_when_extended(
self, tmp_path: Path
) -> None:
"""The re-check time isn't ambiguous — it's whichever one actually applies."""
files = self._files(tmp_path)
now = datetime.now(tz=UTC).astimezone().replace(hour=6, minute=0)
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=True,
weekly_minimum_met=False,
relaxed_day=False,
now=now,
)
assert result.fired is False
assert result.stage == "early_bird_time_fresh"
assert "09:00" in result.reason
assert "08:30" not in result.reason
assert result.stage == "wake_alarm_skip"
assert "early-bird" not in result.reason
def test_relaxed_day_skips(self, tmp_path: Path) -> None:
files = self._files(tmp_path)
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=True,
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
@ -201,7 +174,6 @@ class TestExplainLockDecision:
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=True,
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
@ -214,7 +186,6 @@ class TestExplainLockDecision:
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=True,
relaxed_day=False,
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
@ -227,7 +198,6 @@ class TestExplainLockDecision:
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=False,
now=datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0),
@ -243,7 +213,6 @@ class TestExplainLockDecision:
result = explain_lock_decision(
**files,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=False,
)

View File

@ -2,52 +2,39 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from screen_locker import _compliance_state
from screen_locker._sick_tracker import SickHistory
if TYPE_CHECKING:
from pathlib import Path
class TestEarlyBirdWindowOpen:
"""The early-bird window's start/end boundaries, including the extension."""
class TestEarlyBirdWindowIsTheCarrot:
"""The window is whatever wake-alarm's signed file says -- no wall clock."""
"""Direct tests for the module-private, deliberately independent reimplementation."""
def test_no_clock_predicate_remains(self) -> None:
assert not hasattr(_compliance_state, "_early_bird_window_open")
def test_before_window(self) -> None:
"""Before the start hour the window is shut."""
assert (
_compliance_state._early_bird_window_open(extended=False, local_minutes=299)
is False
def test_window_open_mirrors_wake_skip(self, tmp_path: Path) -> None:
files = {
"log_file": tmp_path / "log.json",
"early_bird_pending_file": tmp_path / "eb.json",
}
open_ = _compliance_state.explain_lock_decision(
**files,
sick_history=SickHistory(),
weekly_minimum_met=False,
relaxed_day=False,
wake_skip=True,
)
def test_at_start(self) -> None:
"""The start minute itself is inside the window."""
assert (
_compliance_state._early_bird_window_open(extended=False, local_minutes=300)
is True
)
def test_before_end(self) -> None:
"""A minute before the end is still inside the window."""
assert (
_compliance_state._early_bird_window_open(extended=False, local_minutes=509)
is True
)
def test_at_end_exclusive(self) -> None:
"""The end minute is exclusive: the window is already shut."""
assert (
_compliance_state._early_bird_window_open(extended=False, local_minutes=510)
is False
)
def test_extended_before_end(self) -> None:
"""With the extension earned, the window runs later."""
assert (
_compliance_state._early_bird_window_open(extended=True, local_minutes=539)
is True
)
def test_extended_at_end_exclusive(self) -> None:
"""The extended end is exclusive too."""
assert (
_compliance_state._early_bird_window_open(extended=True, local_minutes=540)
is False
shut = _compliance_state.explain_lock_decision(
**files,
sick_history=SickHistory(),
weekly_minimum_met=False,
relaxed_day=False,
wake_skip=False,
)
assert open_.stage == "wake_alarm_skip"
assert shut.stage == "would_lock"

View File

@ -1,147 +1,47 @@
"""Tests for early bird carrot feature in screen locker."""
"""The early-bird window is the wake-alarm morning-session carrot."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker.tests.conftest import (
create_locker,
)
from screen_locker._morning_session import MorningSkip
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
from screen_locker.screen_lock import ScreenLocker
class TestGetLocalTimeMinutes:
"""Tests for _get_local_time_minutes helper."""
def test_returns_int_within_day_range(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns an integer between 0 and 1439 (minutes in a day)."""
locker = create_locker(mock_tk, tmp_path)
result = locker._get_local_time_minutes()
assert isinstance(result, int)
assert 0 <= result < 24 * 60
_SKIP = MorningSkip(outcome="completed", exempt_until=datetime(2099, 1, 1).astimezone())
class TestIsEarlyBirdTime:
"""Tests for _is_early_bird_time based on local clock."""
"""``_is_early_bird_time`` is the carrot verdict, kept for the skip detail."""
def _locker(
self,
mock_tk: MagicMock,
tmp_path: Path,
minutes: int,
) -> ScreenLocker:
def test_open_while_the_carrot_is_in_force(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_get_local_time_minutes",
MagicMock(return_value=minutes),
)
return locker
def test_within_window(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""6:00 AM (360 min) is within the early bird window."""
locker = self._locker(mock_tk, tmp_path, 360)
assert locker._is_early_bird_time() is True
def test_at_start_of_window(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""5:00 AM (300 min) is the inclusive start of the window."""
locker = self._locker(mock_tk, tmp_path, 300)
assert locker._is_early_bird_time() is True
def test_just_before_start(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""4:59 AM (299 min) is before the window."""
locker = self._locker(mock_tk, tmp_path, 299)
assert locker._is_early_bird_time() is False
def test_just_before_end(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""8:29 AM (509 min) is still within the window."""
locker = self._locker(mock_tk, tmp_path, 509)
assert locker._is_early_bird_time() is True
def test_at_end_of_window(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""8:30 AM (510 min) is the exclusive end — not in window."""
locker = self._locker(mock_tk, tmp_path, 510)
assert locker._is_early_bird_time() is False
def test_after_window(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""9:00 AM (540 min) is past the window."""
locker = self._locker(mock_tk, tmp_path, 540)
assert locker._is_early_bird_time() is False
def test_extended_window_ends_at_9am(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""When has_extended_early_bird is True, window closes at 09:00 (540 min)."""
locker = self._locker(mock_tk, tmp_path, 539) # 08:59 — still inside
with patch(
"screen_locker._early_bird.has_extended_early_bird",
return_value=True,
):
"screen_locker._early_bird.morning_skip_today", return_value=_SKIP
) as read:
assert locker._is_early_bird_time() is True
read.assert_called_once_with(wait=True)
assert locker._morning_skip == _SKIP
def test_extended_window_closed_at_9am(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
def test_shut_without_a_carrot(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Extended window excludes exactly 09:00 (540 min)."""
locker = self._locker(mock_tk, tmp_path, 540) # 09:00 — exclusive end
with patch(
"screen_locker._early_bird.has_extended_early_bird",
return_value=True,
):
locker = create_locker(mock_tk, tmp_path)
with patch("screen_locker._early_bird.morning_skip_today", return_value=None):
assert locker._is_early_bird_time() is False
assert locker._morning_skip is None
def test_midnight(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
def test_no_wall_clock_is_consulted(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Midnight (0 min) is outside the window."""
locker = self._locker(mock_tk, tmp_path, 0)
"""The 05:00-08:30 clock is gone: 07:00 with a carrot is open, 06:00 without is shut."""
locker = create_locker(mock_tk, tmp_path)
assert not hasattr(locker, "_get_local_time_minutes")
with patch("screen_locker._early_bird.morning_skip_today", return_value=None):
assert locker._is_early_bird_time() is False

View File

@ -125,7 +125,7 @@ class TestInitEarlyBirdFlow:
patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
patch(
"screen_locker._auto_upgrade.morning_skip_today",
"screen_locker._early_bird.morning_skip_today",
return_value=None,
),
pytest.raises(SystemExit),

View File

@ -101,7 +101,6 @@ class TestAfterMidnightLog:
log_file=log_file,
early_bird_pending_file=tmp_path / "early_bird_pending.json",
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=True,
relaxed_day=False,
now=JUST_AFTER_MIDNIGHT_UTC,

View File

@ -11,6 +11,7 @@ from screen_locker import _sick_tracker
from screen_locker._constants import (
SICK_HISTORY_REVIEW_COUNT,
SICK_JUSTIFICATION_MIN_CHARS,
SICK_JUSTIFICATION_PREVIEW_CHARS,
)
from screen_locker._sick_tracker import (
JustificationDraft,
@ -198,3 +199,14 @@ class TestRecentJustifications:
history = SickHistory(justifications=[{}])
out = format_recent_justifications(history)
assert "?" in out
def test_format_keeps_a_long_symptom_to_one_line(self) -> None:
"""A 48-char symptom wrapped and pushed the sick screen past 768 px."""
history = SickHistory(
justifications=[
{"date": "2026-05-01", "symptom": "x" * 48, "severity": 7},
],
)
out = format_recent_justifications(history)
assert out.endswith("…")
assert len(out.split("— ")[1]) == SICK_JUSTIFICATION_PREVIEW_CHARS

View File

@ -62,7 +62,6 @@ class TestUnreadableSourceIsNotNoWorkout:
**self._files(tmp_path),
now=self._NOON,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=False,
degraded_sources=(_sync_client.DegradedSource("firebase", "HTTP 400"),),
@ -77,7 +76,6 @@ class TestUnreadableSourceIsNotNoWorkout:
**self._files(tmp_path),
now=self._NOON,
sick_history=SickHistory(),
extended_early_bird=False,
weekly_minimum_met=False,
relaxed_day=False,
)

View File

@ -122,16 +122,16 @@ class TestEnforcementTimersAreCycleFree:
class TestEarlyBirdTimerWindows:
"""The early-bird timer must keep closing both grace windows."""
"""The early-bird timer must re-check just after both carrot boundaries."""
def test_covers_normal_and_extended_windows(self) -> None:
"""08:30 closes the normal window; 09:05 closes the extended one."""
def test_covers_both_carrot_boundaries(self) -> None:
"""09:30 ends an aborted morning's grace; 11:00 ends a completed one's."""
text = (REPO_ROOT / "early-bird-workout-check.timer").read_text(
encoding="utf-8"
)
calendars = _directive_values(text, "OnCalendar")
assert "*-*-* 08:30:00" in calendars
assert "*-*-* 09:05:00" in calendars
assert "*-*-* 09:30:30" in calendars
assert "*-*-* 11:00:30" in calendars
class TestPeriodicLockerTimer:

View File

@ -1,87 +1,70 @@
"""Tests for wake alarm skip integration in screen_lock.py."""
"""The wake-alarm carrot in the lock's startup ladder (``_check_today_state_exits``)."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from pathlib import Path
from unittest.mock import MagicMock, patch
from screen_locker._morning_session import MorningSkip
from screen_locker.screen_lock import ScreenLocker
from screen_locker.tests.conftest import create_locker
_SKIP = MorningSkip(outcome="completed", exempt_until=datetime(2099, 1, 1).astimezone())
if TYPE_CHECKING:
from pathlib import Path
def _locker(
tmp_path: Path, *, carrot: MorningSkip | None, has_logged: bool = False
) -> None:
"""Build a locker whose early-bird window is the given carrot verdict."""
with (
patch.object(Path, "resolve", return_value=tmp_path / "screen_locker"),
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
patch.object(ScreenLocker, "_is_sick_day_today", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_pending", return_value=False),
patch("screen_locker._early_bird.morning_skip_today", return_value=carrot),
patch.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False),
patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
patch.object(ScreenLocker, "_scan_and_fill_week_runnerup", return_value=0),
):
ScreenLocker(demo_mode=True)
class TestWakeSkipIntegration:
"""Tests for workout skip via wake alarm in screen locker init."""
def test_exits_when_wake_skip_active(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
def test_exits_and_banks_the_marker_while_the_carrot_is_in_force(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Screen locker exits if wake alarm granted workout skip today."""
with patch(
"screen_locker._auto_upgrade.morning_skip_today",
return_value=_SKIP,
with (
patch.object(ScreenLocker, "_save_early_bird_pending") as save,
patch("screen_locker._auto_upgrade.record_decision") as record,
):
create_locker(mock_tk, tmp_path, has_logged=False)
_locker(tmp_path, carrot=_SKIP)
mock_sys_exit.assert_called_once_with(0)
save.assert_called_once()
decision = record.call_args.args[0]
assert decision.reason == "wake_alarm_skip"
assert decision.extra["exempt_until"] == _SKIP.exempt_until.isoformat()
assert "no lock until" in decision.detail
def test_does_not_exit_when_no_wake_skip(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
def test_does_not_exit_without_a_carrot(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Screen locker proceeds normally if no wake skip active."""
with patch(
"screen_locker._auto_upgrade.morning_skip_today",
return_value=None,
):
locker = create_locker(mock_tk, tmp_path, has_logged=False)
_locker(tmp_path, carrot=None)
mock_sys_exit.assert_not_called()
assert locker is not None
def test_logged_today_takes_precedence(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""has_logged_today exits before wake skip is even checked."""
with patch(
"screen_locker._auto_upgrade.morning_skip_today",
return_value=_SKIP,
):
create_locker(mock_tk, tmp_path, has_logged=True)
# Exits because has_logged_today, not because of wake skip
with patch("screen_locker._auto_upgrade.record_decision") as record:
_locker(tmp_path, carrot=_SKIP, has_logged=True)
mock_sys_exit.assert_called_once_with(0)
assert record.call_args.args[0].reason == "workout_logged_today"
def test_verify_only_mode_ignores_wake_skip(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""verify_only mode checks sick day log, not wake skip."""
with patch(
"screen_locker._auto_upgrade.morning_skip_today",
return_value=_SKIP,
):
create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=True,
)
# In verify_only mode, exits don't happen from wake skip path
with patch("screen_locker._early_bird.morning_skip_today", return_value=_SKIP):
create_locker(mock_tk, tmp_path, verify_only=True, is_sick_day_log=True)
mock_sys_exit.assert_not_called()

View File

@ -151,7 +151,7 @@ class TestCheckTodayStateExits:
patch.object(locker, "_is_sick_day_today", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"screen_locker._auto_upgrade.morning_skip_today",
"screen_locker._early_bird.morning_skip_today",
return_value=_SKIP,
),
):
@ -171,7 +171,7 @@ class TestCheckTodayStateExits:
patch.object(locker, "_is_sick_day_today", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"screen_locker._auto_upgrade.morning_skip_today",
"screen_locker._early_bird.morning_skip_today",
return_value=None,
),
patch.object(locker, "_is_early_bird_time", return_value=True),
@ -193,7 +193,7 @@ class TestCheckTodayStateExits:
patch.object(locker, "_is_sick_day_today", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"screen_locker._auto_upgrade.morning_skip_today",
"screen_locker._early_bird.morning_skip_today",
return_value=None,
),
patch.object(locker, "_is_early_bird_time", return_value=False),

35
web/package-lock.json generated
View File

@ -17,12 +17,12 @@
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7",
"@types/node": "26.6.1",
"@types/node": "26.6.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "5.0.1",
"eslint": "10.10.0",
"eslint": "10.11.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7",
"globals": "17.12.0",
@ -124,7 +124,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@ -479,7 +478,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@ -528,7 +526,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@ -1097,7 +1094,6 @@
"integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -1237,12 +1233,11 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz",
"integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==",
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~8.9.0"
}
@ -1253,7 +1248,6 @@
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1264,7 +1258,6 @@
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.3.0"
}
@ -1314,7 +1307,6 @@
"integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.70.0",
"@typescript-eslint/types": "8.70.0",
@ -1549,7 +1541,6 @@
"integrity": "sha512-FRC8ACiudC3dI6MTplzRSYWHDRnIv2IPfbzs4FdoJNsMal/35sWV8hwIfV8ZcqzSPy+uXHeMVONt9CEqtOU17w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/istanbul-lib-coverage": "^1.0.0",
@ -1640,7 +1631,6 @@
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1803,7 +1793,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
@ -2052,12 +2041,11 @@
}
},
"node_modules/eslint": {
"version": "10.10.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz",
"integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==",
"version": "10.11.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz",
"integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==",
"dev": true,
"license": "MIT",
"peer": true,
"workspaces": [
"packages/*"
],
@ -2613,7 +2601,6 @@
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@ -3233,7 +3220,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -3243,7 +3229,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.28.0"
},
@ -3537,7 +3522,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -3634,7 +3618,6 @@
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
@ -3713,7 +3696,6 @@
"integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.1",
@ -3925,7 +3907,6 @@
"integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -23,12 +23,12 @@
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7",
"@types/node": "26.6.1",
"@types/node": "26.6.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "5.0.1",
"eslint": "10.10.0",
"eslint": "10.11.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7",
"globals": "17.12.0",