utils/earned_time/earned_time/_focus.py
Krzysztof kuhy Rudnicki ba4dd58545
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
leetcode-coach pre-commit / pre-commit (push) Has been cancelled
leetcode-coach tests / test (push) Has been cancelled
earned_time 0.21.0: focus day pays 240 gaming
kuhy 2026-11-02: keep the 180 base cut, pay 2 gaming min per focus min
(240 at 120/120), so a full focus day is 8 h again; an idle day stays 2 h.
FocusEarner.cut_gaming_minutes holds the cut (_resolve._gaming_cut) so the
pay (cut + pure bonuses again) can exceed it. Shutdown unchanged. Carry
rows (focus_project 0.6.0, <project>-<YYYYMMDD>-carry) pay like own rows;
test added. Failure modes unchanged: focus_project unusable = no project
(fail-open, logged); unreadable ledger = None, earns nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:22:10 +01:00

122 lines
4.1 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""The focus project (0.19.0): replaced gates stand down, one earner pays."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
import importlib
import logging
from typing import Any, Final
from earned_time._ladder import shutdown_units_minutes
from earned_time._match import focus_match
from earned_time._policy import Earner
__all__ = [
"FOCUS_LEDGER",
"FOCUS_WATCH",
"FocusEarner",
"focus_earner",
"with_focus",
]
FOCUS_LEDGER: Final = ".local/share/focus_project/ledger.json"
_SYNONYMS: Final = {"book": "reading"}
_logger: Final = logging.getLogger(__name__)
_warned: set[str] = set()
@dataclass(frozen=True)
class FocusEarner(Earner):
"""Counted; ``gaming_minutes``/``shutdown_minutes`` pay a FULL day.
``cut_gaming_minutes``: the gaming base cut, all the replaced gates paid,
LeetCode's free hour too (0.20.0). The full-day gaming pay adds that free
hour once more, so a full day matches the old one (kuhy 2026-11-02, 0.21.0).
"""
cut_gaming_minutes: int = 0
def gaming_for(self, units: int) -> int:
"""Gaming minutes of ``units`` focused minutes, floored."""
return self.capped(units) * self.gaming_minutes // (self.max_units or 1)
def shutdown_for(
self,
units: int,
day: date | None = None, # noqa: ARG002
) -> int:
"""Shutdown minutes of ``units`` focused minutes, floored."""
return self.capped(units) * self.shutdown_minutes // (self.max_units or 1)
def _active(day: date) -> Any: # noqa: ANN401
"""The active project on ``day``; ``None`` for none or on any failure."""
try:
return importlib.import_module("focus_project").active(day)
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
message = f"focus_project unusable ({type(exc).__name__}: {exc})"
if message not in _warned:
_warned.add(message)
_logger.warning("%s; behaving as if no project is active", message)
return None
def daily_on(project: Any, day: date) -> int: # noqa: ANN401
"""``day``'s target: ``project.daily_on(day)`` (a later edit never re-prices it)."""
pick = getattr(project, "daily_on", None)
return int(pick(day) if pick is not None else project.daily_minutes)
def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401
"""The ``focus`` earner paying what ``dropped`` paid, over ``project``.
Gaming: the cut is every dropped gate's full pay; a full day pays that plus
the pure bonuses (no ``penalty_from``) again: 180 + 60 = 240 at 120 min.
"""
cut = sum(e.max_gaming_minutes for e in dropped)
free = tuple(e for e in dropped if e.penalty_from is None)
return FocusEarner(
name="focus",
label="Focus",
gaming_minutes=cut + sum(e.max_gaming_minutes for e in free),
cut_gaming_minutes=cut,
shutdown_minutes=sum(
shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped
),
kind="counted",
max_units=daily_on(project, day),
penalty_from=project.start,
confirmed_on=project.start - timedelta(days=1),
ledger=FOCUS_LEDGER,
match=focus_match,
missing_ledger_is_no=True,
)
FOCUS_WATCH: Final = FocusEarner(
name="focus",
label="Focus",
gaming_minutes=0,
shutdown_minutes=0,
kind="counted",
max_units=1,
ledger=FOCUS_LEDGER,
match=focus_match,
missing_ledger_is_no=True,
)
def with_focus(registry: tuple[Earner, ...], day: date) -> tuple[Earner, ...]:
"""``registry`` reshaped by the active project; unchanged if none."""
project = _active(day)
if project is None:
return registry
gates = {_SYNONYMS.get(g, g) for g in project.replaces}
dropped = tuple(e for e in registry if e.name in gates)
if not dropped or daily_on(project, day) < 1:
return registry
kept = tuple(e for e in registry if e not in dropped)
return (*kept, focus_earner(project, dropped, day))