playtime: drop the Fri-Mon carry-over; log budget on change only

A Sunday ran past the 6.0h the i3 bar showed: 2h47m of Saturday's
leftover was carried into the budget the cutoff used, and the 3-second
"Gaming budget" journal line omitted it. Unspent time is now dropped at
06:00 every day; load_state takes a legacy ledger's share back out of
today's high-water mark. The budget line logs only on change (was 25k
lines/day), fired warnings are logged, and budget.jsonl records carry
budget_seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PrwCnQouKTF4EUMENMxnG
This commit is contained in:
Krzysztof Rudnicki 2026-10-18 21:37:29 +02:00
parent 157365ff48
commit a09f08701a
23 changed files with 176 additions and 310 deletions

View File

@ -36,8 +36,8 @@ server: `_ctl_client.py` (results in `_ctl_types.py`, errors in `_ctl_protocol.p
## Gaming reset ## Gaming reset
`_gaming_reset.reset_today`, shared with the CLI: zeroes billed time and per-game `_gaming_reset.reset_today`, shared with the CLI: zeroes billed time and per-game
attribution, re-arms warnings, releases the mounts, keeps earned time (`carry`, attribution, re-arms warnings, releases the mounts, keeps earned time
`budget_seconds`; the old CLI path dropped both), and writes a `manual_adjustment` (`budget_seconds`; the old CLI path dropped it), and writes a `manual_adjustment`
record to `/var/log/steam-backlog-enforcer/budget.jsonl` (billed seconds and record to `/var/log/steam-backlog-enforcer/budget.jsonl` (billed seconds and
per-game breakdown before). That record is the auditable trail; the per-day per-game breakdown before). That record is the auditable trail; the per-day
history mirrors current billed time, so it shows the day as quiet. history mirrors current billed time, so it shows the day as quiet.
@ -55,7 +55,7 @@ whole downtime. Everything else already lives on disk after every tick:
| Billed seconds, per-game attribution | yes | `playtime_state.json` saved every tick; the final tick bills up to the exit, the first tick of the new daemon bills the gap | | Billed seconds, per-game attribution | yes | `playtime_state.json` saved every tick; the final tick bills up to the exit, the first tick of the new daemon bills the gap |
| Cutoff / kill-grace timing | yes | `blocked_at` is persisted; `_sustain_block` measures from it | | Cutoff / kill-grace timing | yes | `blocked_at` is persisted; `_sustain_block` measures from it |
| Playtime mounts (the block itself) | yes | kernel mount table; `reconcile` is mountinfo-driven. `RuntimeDirectoryPreserve=yes` keeps the stub file | | Playtime mounts (the block itself) | yes | kernel mount table; `reconcile` is mountinfo-driven. `RuntimeDirectoryPreserve=yes` keeps the stub file |
| Fired warnings, carry ledger | yes | in `PlaytimeState` | | Fired warnings, budget high-water | yes | in `PlaytimeState` |
| Open store window | yes (blip) | deadline in `state.json`; startup re-blocks the store once, the first tick re-opens it | | Open store window | yes (blip) | deadline in `state.json`; startup re-blocks the store once, the first tick re-opens it |
| Total block | yes | lock file, guard-lib | | Total block | yes | lock file, guard-lib |
| Armed gaming reset | no | memory only: arm again (fails closed) | | Armed gaming reset | no | memory only: arm again (fails closed) |

View File

@ -1,22 +0,0 @@
# Log the Fri->Sat/Sun/Mon carry in the budget resolver line
Carried over from the 2026-10-10 tutor-cutover handoff (item 4b, optional).
## what
Saturday's budget showed 15144 s = 14400 + 744 s carried over from Friday
(`_gaming_days.py` `pass_on`, commit 9586128). That is intended, but the
"Gaming budget ..." log line (`_budget_resolve.py` ~:176) does not show the
carry, so the number looks wrong. Include the carry seconds in that line.
## where
`steam_backlog_enforcer/_budget_resolve.py`, carry from
`_playtime_budget.py` (`PlaytimeState.carry`).
## must
- must not: change the budget arithmetic — log only.
## done
On a carry day the journal line reads like
`Gaming budget ... (+744 s carried from Fri)`; a unit test asserts it.
REMOVE ME AFTER FINISH

View File

@ -28,7 +28,7 @@ while the gaming day runs to 05:59, and a cold restart resolves with empty
answer caches, so a live answer can come in lower than one given hours ago. answer caches, so a live answer can come in lower than one given hours ago.
That bit twice (2026-10-03 and 2026-10-04 00:00, 8h -> 4h, unwarned cutoff). That bit twice (2026-10-03 and 2026-10-04 00:00, 8h -> 4h, unwarned cutoff).
``PlaytimeState.budget_seconds`` is the day's high-water mark, and ``PlaytimeState.budget_seconds`` is the day's high-water mark, and
``rules_for`` applies ``max(resolved + carry, held)`` via ``rules_for`` applies ``max(resolved, held)`` via
``_gaming_days.held_today`` -- so the daemon, ``/api/budget`` and MCP all see ``_gaming_days.held_today`` -- so the daemon, ``/api/budget`` and MCP all see
the held figure. Only the 06:00 roll-over lowers it. the held figure. Only the 06:00 roll-over lowers it.
@ -64,6 +64,8 @@ logger = logging.getLogger(__name__)
_SECONDS_PER_MINUTE: Final = 60.0 _SECONDS_PER_MINUTE: Final = 60.0
_SECONDS_PER_HOUR: Final = 3600.0 _SECONDS_PER_HOUR: Final = 3600.0
# The last reason logged, so a long-lived daemon logs a budget only on change.
_last_logged: Final[dict[str, str]] = {}
# How each earner reads in the reason string: (earned, missed, unknown). # How each earner reads in the reason string: (earned, missed, unknown).
# An earner registered later without an entry here gets a generic phrase. # An earner registered later without an entry here gets a generic phrase.
@ -173,6 +175,10 @@ def resolve_budget(config: Config) -> BudgetResolution:
_describe(t.earner.name, t.earner.label, t.answer) for t in day.terms _describe(t.earner.name, t.earner.label, t.answer) for t in day.terms
) )
reason = f"{total / _SECONDS_PER_HOUR:.1f}h: {parts}" reason = f"{total / _SECONDS_PER_HOUR:.1f}h: {parts}"
# The daemon resolves every tick (3 s): an unchanged answer logged each
# time was 25k identical journal lines a day that buried everything else.
if reason != _last_logged.get("reason"):
_last_logged["reason"] = reason
logger.info("Gaming budget %s", reason) logger.info("Gaming budget %s", reason)
return BudgetResolution( return BudgetResolution(
seconds=total, seconds=total,

View File

@ -167,8 +167,6 @@ def build_rules(rules: PlaytimeRules) -> dict[str, Any]:
# is what would let this view report a budget the daemon is not # is what would let this view report a budget the daemon is not
# enforcing. See _budget_resolve's "one seam". # enforcing. See _budget_resolve's "one seam".
"budget_reason": rules.budget_reason, "budget_reason": rules.budget_reason,
# Unspent Fri-Sun time inherited by today; already inside budget_seconds.
"carry_seconds": rules.carry_seconds,
"bonuses": { "bonuses": {
"base": rules.base_seconds, "base": rules.base_seconds,
"workout": rules.workout_seconds, "workout": rules.workout_seconds,

View File

@ -1,24 +1,13 @@
"""Gaming-day arithmetic: which day *now* is, and what it inherits. """Gaming-day arithmetic: which day *now* is, and what it already granted.
A gaming "day" runs 06:00-05:59 local, so late-night sessions count against A gaming "day" runs 06:00-05:59 local, so late-night sessions count against
the day they started in. the day they started in.
**Free-day carry-over.** Friday to Monday is one block. Whatever a Friday, There is no carry-over: unspent time is dropped at 06:00, every day. The
Saturday or Sunday leaves unspent is split evenly, with no strings attached, Friday-Monday carry ledger was removed on 2026-10-18, after a Sunday ran
across the days still left in that block: past the 6.0h the i3 bar showed because 2h47m had been carried into it.
``load_state`` takes a legacy ledger's share back out of the stored
Fri leftover -> Sat, Sun, Mon (a third each) high-water mark.
Sat leftover -> Sun, Mon (half each)
Sun leftover -> Mon
Monday's leftover, and any Tuesday-Thursday leftover, is dropped. A day's
leftover is measured against its whole budget *including* what it inherited,
so time carried into Saturday and not played moves on to Sunday and Monday
rather than being lost.
The ledger lives on ``PlaytimeState`` -- root-owned and immutable -- and never
in ``playtime_history.json``: that file is user-writable, and a stored budget
read from it would let anyone grant themselves unlimited time.
Everything here is pure: it works on primitives handed to it, so it imports Everything here is pure: it works on primitives handed to it, so it imports
nothing from the playtime modules and cannot close an import cycle. nothing from the playtime modules and cannot close an import cycle.
@ -26,7 +15,7 @@ nothing from the playtime modules and cannot close an import cycle.
from __future__ import annotations from __future__ import annotations
from datetime import date, timedelta from datetime import timedelta
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING, Final
if TYPE_CHECKING: if TYPE_CHECKING:
@ -37,10 +26,6 @@ if TYPE_CHECKING:
# The gaming day starts here, local time. 05:59 belongs to the previous day. # The gaming day starts here, local time. 05:59 belongs to the previous day.
_DAY_BOUNDARY_HOURS: Final = 6 _DAY_BOUNDARY_HOURS: Final = 6
# Weekday (Monday == 0) whose leftover is passed on -> how many later days of
# the Friday-Monday block share it.
_SHARES: Final = {4: 3, 5: 2, 6: 1}
def gaming_day_key(now: datetime) -> str: def gaming_day_key(now: datetime) -> str:
"""Return the ``YYYY-MM-DD`` gaming day containing *now*. """Return the ``YYYY-MM-DD`` gaming day containing *now*.
@ -56,97 +41,6 @@ def gaming_day_key(now: datetime) -> str:
return (now - timedelta(hours=_DAY_BOUNDARY_HOURS)).date().isoformat() return (now - timedelta(hours=_DAY_BOUNDARY_HOURS)).date().isoformat()
def pass_on(
carry: dict[str, float],
*,
outgoing_day: str,
budget: float,
spent: float,
new_day: str,
) -> dict[str, float]:
"""Return the carry ledger as it stands once *new_day* begins.
Shares owed to days before *new_day* are dropped, which both bounds the
ledger and means a day the daemon never saw simply forfeits its share.
Args:
carry: Day key to seconds owed, as held by the outgoing day.
outgoing_day: The gaming day that just ended; ``""`` if none.
budget: The outgoing day's budget, carry included.
spent: Seconds billed during the outgoing day.
new_day: The gaming day now starting.
Returns:
The ledger for *new_day*, the outgoing day's leftover added.
"""
owed = {day: seconds for day, seconds in carry.items() if day >= new_day}
if not outgoing_day:
return owed
ended = date.fromisoformat(outgoing_day)
shares = _SHARES.get(ended.weekday(), 0)
leftover = max(0.0, budget - spent)
for offset in range(1, shares + 1):
target = (ended + timedelta(days=offset)).isoformat()
if target >= new_day:
owed[target] = owed.get(target, 0.0) + leftover / shares
return owed
def carry_due(
carry: dict[str, float],
*,
stored_day: str,
budget: float,
spent: float,
today: str,
) -> float:
"""Return the seconds carried into *today*.
The stored record may still describe yesterday -- the first tick after
06:00 resolves the budget before rolling the state over -- so the ledger is
projected forward exactly as ``roll_over`` will do it.
Args:
carry: The stored ledger.
stored_day: The gaming day the stored record covers.
budget: That day's budget, carry included.
spent: Seconds billed during that day.
today: The current gaming day.
Returns:
Seconds inherited by *today*, or 0.
"""
owed = (
carry
if stored_day == today
else pass_on(
carry, outgoing_day=stored_day, budget=budget, spent=spent, new_day=today
)
)
return owed.get(today, 0.0)
def carry_into(stored: PlaytimeState | None, now: datetime) -> float:
"""Return the seconds carried into the gaming day containing *now*.
Args:
stored: The persisted production state, or ``None`` if unreadable.
now: Local timestamp for this tick.
Returns:
The carry owed to today; 0 when no state is recorded.
"""
if stored is None:
return 0.0
return carry_due(
stored.carry,
stored_day=stored.day_key,
budget=stored.budget_seconds,
spent=stored.seconds,
today=gaming_day_key(now),
)
def held_today(stored: PlaytimeState | None, now: datetime) -> float: def held_today(stored: PlaytimeState | None, now: datetime) -> float:
"""Return the budget already granted earlier in *now*'s gaming day. """Return the budget already granted earlier in *now*'s gaming day.

View File

@ -56,10 +56,9 @@ class ResetOutcome:
def reset_today(*, source: str) -> ResetOutcome: def reset_today(*, source: str) -> ResetOutcome:
"""Zero today's billed time, drop the block and record the adjustment. """Zero today's billed time, drop the block and record the adjustment.
The earned-time bookkeeping (``carry`` from earlier free days, the day's The day's ``budget_seconds`` high-water mark is kept: resetting spent
``budget_seconds`` high-water mark) is kept: resetting spent time must not time must not also revoke time already earned. Warnings re-arm because
also revoke time already earned. Warnings re-arm because ``seconds`` is ``seconds`` is back at zero.
back at zero.
The reset is written to the budget audit log as a ``manual_adjustment`` The reset is written to the budget audit log as a ``manual_adjustment``
(with what was billed before, per game); that record is the auditable (with what was billed before, per game); that record is the auditable

View File

@ -56,9 +56,8 @@ def get_gaming_time() -> dict[str, Any]:
"seconds_used": today["seconds_used"], "seconds_used": today["seconds_used"],
"budget_seconds": today["budget_seconds"], "budget_seconds": today["budget_seconds"],
"seconds_remaining": today["seconds_remaining"], "seconds_remaining": today["seconds_remaining"],
# What earned budget_seconds, Friday-Monday carry-over included. # What earned budget_seconds.
"budget_reason": rules["budget_reason"], "budget_reason": rules["budget_reason"],
"carry_seconds": rules["carry_seconds"],
"next_warning_seconds": today["next_warning_seconds"], "next_warning_seconds": today["next_warning_seconds"],
"blocked": today["blocked"], "blocked": today["blocked"],
"enforcement": rules["enforcement"], "enforcement": rules["enforcement"],

View File

@ -11,7 +11,6 @@ from dataclasses import replace
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from steam_backlog_enforcer._gaming_days import pass_on
from steam_backlog_enforcer._playtime_state import ( from steam_backlog_enforcer._playtime_state import (
_DELTA_CLAMP_FACTOR, _DELTA_CLAMP_FACTOR,
PlaytimeRules, PlaytimeRules,
@ -80,9 +79,8 @@ def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState:
"""Reset *state* if the gaming day has changed. """Reset *state* if the gaming day has changed.
Pure. ``last_tick_at`` carries across the boundary so the first tick of the Pure. ``last_tick_at`` carries across the boundary so the first tick of the
new day measures a sane delta rather than restarting from zero, and the carry new day measures a sane delta rather than restarting from zero. Nothing else
ledger crosses it with the outgoing day's leftover added (see does: the outgoing day's unspent time is dropped.
``_gaming_days``).
Args: Args:
state: Current accounting state. state: Current accounting state.
@ -93,17 +91,7 @@ def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState:
""" """
if state.day_key == day_key: if state.day_key == day_key:
return state return state
return PlaytimeState( return PlaytimeState(day_key=day_key, last_tick_at=state.last_tick_at)
day_key=day_key,
last_tick_at=state.last_tick_at,
carry=pass_on(
state.carry,
outgoing_day=state.day_key,
budget=state.budget_seconds,
spent=state.seconds,
new_day=day_key,
),
)
def pending_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None: def pending_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None:

View File

@ -65,10 +65,14 @@ def _warn(state: PlaytimeState, rules: PlaytimeRules) -> PlaytimeState:
if threshold is None: if threshold is None:
return state return state
notify_desktop_user( left = f"{_humanise(threshold)} of today's gaming budget left."
"Gaming time running out", logger.info(
f"{_humanise(threshold)} of today's gaming budget left.", "Gaming warning: %s (billed %.0fs of %.0fs)",
left,
state.seconds,
rules.budget_seconds,
) )
notify_desktop_user("Gaming time running out", left)
return replace(state, warned_seconds=[*state.warned_seconds, threshold]) return replace(state, warned_seconds=[*state.warned_seconds, threshold])

View File

@ -0,0 +1,44 @@
"""Read-side compatibility for the removed Friday-Monday carry-over.
Exists only to read ``playtime_state.json`` records written before carry-over
was removed on 2026-10-18. Such a record lives at most one gaming day (the
next ``save_state`` drops the ``carry`` key), so this module can be deleted
once no such record can remain.
"""
from __future__ import annotations
from dataclasses import replace
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# Type-only: _playtime_state imports this module at runtime.
from steam_backlog_enforcer._playtime_state import PlaytimeState
logger = logging.getLogger(__name__)
def without_legacy_carry(state: PlaytimeState, carry: object) -> PlaytimeState:
"""Take a removed carry ledger's share for the day out of its high-water.
Records written before 2026-10-18 folded the Friday-Monday carry into
``budget_seconds``, and ``held_today`` would keep paying it out for the
rest of that day. The key itself is dropped by the next ``save_state``.
Args:
state: The record as loaded.
carry: The raw ``carry`` value from the file, if any.
Returns:
*state*, its ``budget_seconds`` lowered by the day's carried share.
"""
if not isinstance(carry, dict):
return state
share = carry.get(state.day_key, 0.0)
if not isinstance(share, int | float) or share <= 0:
return state
logger.warning(
"Dropping %.0fs of legacy carry-over from %s's budget.", share, state.day_key
)
return replace(state, budget_seconds=max(0.0, state.budget_seconds - share))

View File

@ -223,5 +223,6 @@ def _snapshot(
"qualifying": sorted(qualifying), "qualifying": sorted(qualifying),
"day_key": state.day_key, "day_key": state.day_key,
"billed_seconds": round(state.seconds, 3), "billed_seconds": round(state.seconds, 3),
"budget_seconds": round(rules.budget_seconds, 3),
"remaining_seconds": round(rules.budget_seconds - state.seconds, 3), "remaining_seconds": round(rules.budget_seconds - state.seconds, 3),
} }

View File

@ -1,6 +1,6 @@
"""The daily playtime budget: its state, its rules, and its persistence. """The daily playtime budget: its state, its rules, and its persistence.
Gaming days and the Friday-Monday carry-over live in ``_gaming_days``. State Gaming days and the held budget live in ``_gaming_days``. State
is written atomically and re-read every tick, which is what lets the enforcer is written atomically and re-read every tick, which is what lets the enforcer
survive a restart mid-session. survive a restart mid-session.
""" """
@ -14,7 +14,8 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from steam_backlog_enforcer._budget_resolve import resolve_budget from steam_backlog_enforcer._budget_resolve import resolve_budget
from steam_backlog_enforcer._gaming_days import carry_into, held_today from steam_backlog_enforcer._gaming_days import held_today
from steam_backlog_enforcer._playtime_legacy import without_legacy_carry
from steam_backlog_enforcer._whitelist_locking import ( from steam_backlog_enforcer._whitelist_locking import (
_try_set_immutable, _try_set_immutable,
unlock_for_write, unlock_for_write,
@ -95,12 +96,10 @@ class PlaytimeState:
warned_seconds: list[int] = field(default_factory=list) warned_seconds: list[int] = field(default_factory=list)
"""Warning thresholds already fired during ``day_key``.""" """Warning thresholds already fired during ``day_key``."""
budget_seconds: float = 0.0 budget_seconds: float = 0.0
"""Highest budget in force during ``day_key``, carry included; ``0.0`` if unset. """Highest budget in force during ``day_key``; ``0.0`` if unset.
What ``roll_over`` measures the outgoing day's leftover against. What ``held_today`` holds the day at when a live re-resolution dips.
""" """
carry: dict[str, float] = field(default_factory=dict)
"""Day key to seconds carried into it from earlier days of the block."""
def is_blocked(self) -> bool: def is_blocked(self) -> bool:
"""Whether the cutoff has engaged for this gaming day. """Whether the cutoff has engaged for this gaming day.
@ -133,7 +132,6 @@ class PlaytimeRules:
reading_seconds: float = 0.0 reading_seconds: float = 0.0
earned_seconds: dict[str, float] = field(default_factory=dict) earned_seconds: dict[str, float] = field(default_factory=dict)
budget_reason: str = "" budget_reason: str = ""
carry_seconds: float = 0.0
def rules_for(config: Config, *, demo: bool) -> PlaytimeRules: def rules_for(config: Config, *, demo: bool) -> PlaytimeRules:
@ -153,12 +151,10 @@ def rules_for(config: Config, *, demo: bool) -> PlaytimeRules:
# Resolved *here*, not per caller, so the daemon (one Config for life) and # Resolved *here*, not per caller, so the daemon (one Config for life) and
# _budget_view (Config per request) can never report different budgets. # _budget_view (Config per request) can never report different budgets.
resolved = None if demo else resolve_budget(config) resolved = None if demo else resolve_budget(config)
# Carry sits on top of the 8h cap (earned, not spent: no strings). The # The day never re-prices below what it already granted -- see held_today.
# day never re-prices below what it already granted -- see held_today.
now = datetime.now().astimezone() now = datetime.now().astimezone()
stored = None if demo else load_state(demo=False) stored = None if demo else load_state(demo=False)
carry = 0.0 if demo else carry_into(stored, now) live = 0.0 if resolved is None else resolved.seconds
live = 0.0 if resolved is None else resolved.seconds + carry
held = held_today(stored, now) held = held_today(stored, now)
held_note = f"; held at {held / 3600:.1f}h (earned earlier today)" held_note = f"; held at {held / 3600:.1f}h (earned earlier today)"
return PlaytimeRules( return PlaytimeRules(
@ -178,11 +174,8 @@ def rules_for(config: Config, *, demo: bool) -> PlaytimeRules:
budget_reason=( budget_reason=(
"demo run" "demo run"
if resolved is None if resolved is None
else resolved.reason else resolved.reason + (held_note if held > live else "")
+ (f", +{carry / 60:.0f}m carried over" if carry > 0 else "")
+ (held_note if held > live else "")
), ),
carry_seconds=carry,
) )
@ -227,7 +220,8 @@ def load_state(*, demo: bool) -> PlaytimeState | None:
logger.warning("Playtime state at %s has an unknown schema.", path) logger.warning("Playtime state at %s has an unknown schema.", path)
return None return None
known = {f.name for f in fields(PlaytimeState)} known = {f.name for f in fields(PlaytimeState)}
return PlaytimeState(**{k: v for k, v in data.items() if k in known}) state = PlaytimeState(**{k: v for k, v in data.items() if k in known})
return without_legacy_carry(state, data.get("carry"))
def save_state(state: PlaytimeState, *, demo: bool) -> None: def save_state(state: PlaytimeState, *, demo: bool) -> None:

View File

@ -15,7 +15,7 @@ from unittest.mock import patch
import earned_time import earned_time
import pytest import pytest
from steam_backlog_enforcer import _leetcode_bonus, _workout_budget from steam_backlog_enforcer import _budget_resolve, _leetcode_bonus, _workout_budget
from steam_backlog_enforcer._budget_resolve import resolve_budget from steam_backlog_enforcer._budget_resolve import resolve_budget
from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.config import Config
from steam_backlog_enforcer.tests._budget_dates import AFTER_CUT, pin_today from steam_backlog_enforcer.tests._budget_dates import AFTER_CUT, pin_today
@ -41,9 +41,11 @@ def _clear_caches() -> Iterator[None]:
""" """
_workout_budget.reset_cache() _workout_budget.reset_cache()
_leetcode_bonus.reset_cache() _leetcode_bonus.reset_cache()
_budget_resolve._last_logged.clear()
yield yield
_workout_budget.reset_cache() _workout_budget.reset_cache()
_leetcode_bonus.reset_cache() _leetcode_bonus.reset_cache()
_budget_resolve._last_logged.clear()
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -202,3 +204,20 @@ class TestTheExplanation:
assert "no counted workout" in caplog.text assert "no counted workout" in caplog.text
assert "no LeetCode solve recorded" in caplog.text assert "no LeetCode solve recorded" in caplog.text
assert "no reading credited" in caplog.text assert "no reading credited" in caplog.text
def test_an_unchanged_budget_is_logged_once(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""The daemon resolves every 3 s; only a change is worth a journal line.
Args:
caplog: pytest's log capture.
"""
with (
caplog.at_level(logging.INFO),
patch(_WORKOUT, return_value=False),
patch(_LEETCODE, return_value=False),
):
resolve_budget(Config())
resolve_budget(Config())
assert caplog.text.count("Gaming budget") == 1

View File

@ -28,11 +28,10 @@ from steam_backlog_enforcer.tests._no_workout_http import earned_budget, fixed_b
# effect yet -- without this patch, collecting this module makes a real HTTP # effect yet -- without this patch, collecting this module makes a real HTTP
# call to the screen locker and pins RULES to whatever budget today happens to # call to the screen locker and pins RULES to whatever budget today happens to
# have earned. Stub it explicitly so the constant is the earned budget always. # have earned. Stub it explicitly so the constant is the earned budget always.
# The carry is pinned for the same reason: conftest's redirects are not active # The held budget is pinned for the same reason: conftest's redirects are not
# at import either, so rules_for would read the LIVE playtime_state.json and # active at import either, so rules_for would read the LIVE playtime_state.json
# fold whatever Fri-Mon carry it holds today into RULES. # and hold RULES at whatever high-water mark it records today.
with ( with (
patch("steam_backlog_enforcer._playtime_state.carry_into", return_value=0.0),
patch("steam_backlog_enforcer._playtime_state.held_today", return_value=0.0), patch("steam_backlog_enforcer._playtime_state.held_today", return_value=0.0),
patch( patch(
"steam_backlog_enforcer._playtime_state.resolve_budget", "steam_backlog_enforcer._playtime_state.resolve_budget",
@ -224,7 +223,6 @@ class TestBuildBudgetSnapshot:
"demo", "demo",
"masked_launchers", "masked_launchers",
"budget_reason", "budget_reason",
"carry_seconds",
"bonuses", "bonuses",
"earners", "earners",
} }

View File

@ -1,19 +1,23 @@
"""Tests for the Friday-Monday carry-over in _gaming_days.""" """Tests for _gaming_days: the day boundary, the held budget, no carry-over."""
from __future__ import annotations from __future__ import annotations
from dataclasses import replace from dataclasses import fields
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import json
from unittest.mock import patch from unittest.mock import patch
from steam_backlog_enforcer._gaming_days import ( import pytest
carry_due,
carry_into, from steam_backlog_enforcer._gaming_days import held_today
held_today,
pass_on,
)
from steam_backlog_enforcer._playtime_budget import roll_over from steam_backlog_enforcer._playtime_budget import roll_over
from steam_backlog_enforcer._playtime_state import PlaytimeState, rules_for from steam_backlog_enforcer._playtime_state import (
PlaytimeRules,
PlaytimeState,
load_state,
rules_for,
state_path,
)
from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.config import Config
HOUR = 3600.0 HOUR = 3600.0
@ -22,105 +26,52 @@ LOCAL = timezone(timedelta(hours=2))
THU, FRI, SAT, SUN, MON, TUE = (f"2026-10-{d:02d}" for d in range(1, 7)) THU, FRI, SAT, SUN, MON, TUE = (f"2026-10-{d:02d}" for d in range(1, 7))
def _leftover(day: str, *, budget: float, spent: float, new_day: str) -> dict: class TestNoCarryOver:
return pass_on({}, outgoing_day=day, budget=budget, spent=spent, new_day=new_day) """Unspent time never moves to a later day (carry-over dropped 2026-10-18)."""
def test_a_saturday_leftover_is_not_passed_on(self) -> None:
class TestPassOn: sun = roll_over(
"""Who inherits a day's unspent budget.""" PlaytimeState(day_key=SAT, seconds=HOUR, budget_seconds=8 * HOUR),
day_key=SUN,
def test_friday_splits_three_ways(self) -> None:
owed = _leftover(FRI, budget=8 * HOUR, spent=7 * HOUR, new_day=SAT)
assert owed == {SAT: 1200.0, SUN: 1200.0, MON: 1200.0}
def test_saturday_splits_two_ways(self) -> None:
owed = _leftover(SAT, budget=8 * HOUR, spent=7 * HOUR, new_day=SUN)
assert owed == {SUN: 1800.0, MON: 1800.0}
def test_sunday_goes_to_monday(self) -> None:
assert _leftover(SUN, budget=HOUR, spent=0.0, new_day=MON) == {MON: HOUR}
def test_monday_and_weekdays_drop_their_leftover(self) -> None:
assert _leftover(MON, budget=HOUR, spent=0.0, new_day=TUE) == {}
assert _leftover(THU, budget=HOUR, spent=0.0, new_day=FRI) == {}
def test_overspent_day_carries_nothing(self) -> None:
owed = _leftover(SUN, budget=HOUR, spent=2 * HOUR, new_day=MON)
assert owed == {MON: 0.0}
def test_no_outgoing_day_keeps_only_future_shares(self) -> None:
owed = pass_on(
{FRI: 5.0, SUN: 7.0}, outgoing_day="", budget=0.0, spent=0.0, new_day=SAT
) )
assert owed == {SUN: 7.0} assert sun == PlaytimeState(day_key=SUN)
assert "carry" not in vars(sun)
def test_a_skipped_day_forfeits_its_share(self) -> None: def test_rules_have_no_carry(self) -> None:
owed = _leftover(FRI, budget=3 * HOUR, spent=0.0, new_day=SUN) assert "carry_seconds" not in {f.name for f in fields(PlaytimeRules)}
assert owed == {SUN: HOUR, MON: HOUR}
def test_shares_accumulate_with_earlier_ones(self) -> None: def test_a_legacy_carry_is_taken_out_of_todays_high_water(self) -> None:
owed = pass_on( # The live file on 2026-10-18 held 6.0h earned + 9994s carried into the
{SUN: 600.0, MON: 600.0}, # day's high-water mark; held_today would otherwise keep paying it out.
outgoing_day=SAT, legacy = {
budget=HOUR, "schema_version": 1,
spent=0.0, "day_key": SUN,
new_day=SUN, "seconds": 6 * HOUR,
"budget_seconds": 6 * HOUR + 9994.0,
"carry": {SUN: 9994.0, MON: 9994.0},
}
state_path(demo=False).write_text(json.dumps(legacy), encoding="utf-8")
stored = load_state(demo=False)
assert stored is not None
assert stored.budget_seconds == 6 * HOUR
assert "carry" not in vars(stored)
@pytest.mark.parametrize(
"carry", [{MON: 9994.0}, {SUN: "9994"}, {SUN: 0.0}, [9994.0]]
) )
assert owed == {SUN: 2400.0, MON: 2400.0} def test_a_ledger_owing_today_nothing_leaves_the_budget(
self, carry: object
) -> None:
class TestCarryDue: legacy = {
"""Today's share, whether or not the stored record has rolled over.""" "schema_version": 1,
"day_key": SUN,
def test_same_day_reads_the_ledger(self) -> None: "budget_seconds": 6 * HOUR,
due = carry_due({SAT: 60.0}, stored_day=SAT, budget=0.0, spent=0.0, today=SAT) "carry": carry,
assert due == 60.0 }
state_path(demo=False).write_text(json.dumps(legacy), encoding="utf-8")
def test_stale_record_is_projected_forward(self) -> None: stored = load_state(demo=False)
due = carry_due({}, stored_day=FRI, budget=4 * HOUR, spent=HOUR, today=SAT) assert stored is not None
assert due == HOUR assert stored.budget_seconds == 6 * HOUR
class TestCarryInto:
"""Reading the carry off a stored state."""
def test_no_state_means_no_carry(self) -> None:
assert carry_into(None, datetime(2026, 10, 3, 10, tzinfo=LOCAL)) == 0.0
def test_reads_the_projected_share(self) -> None:
stored = PlaytimeState(day_key=FRI, seconds=HOUR, budget_seconds=4 * HOUR)
assert carry_into(stored, datetime(2026, 10, 3, 10, tzinfo=LOCAL)) == HOUR
class TestRollOverCarry:
"""roll_over threads the ledger across the 06:00 boundary."""
def test_unspent_carry_moves_on(self) -> None:
sat = roll_over(
PlaytimeState(day_key=FRI, seconds=7 * HOUR, budget_seconds=8 * HOUR),
day_key=SAT,
)
sat = replace(sat, seconds=8 * HOUR, budget_seconds=8 * HOUR + 1200.0)
sun = roll_over(sat, day_key=SUN)
assert sun.carry == {SUN: 1800.0, MON: 1800.0}
class TestRulesCarry:
"""rules_for adds the carry above the cap and says so."""
def test_carry_lands_in_budget_and_reason(self) -> None:
with patch(
"steam_backlog_enforcer._playtime_state.carry_into", return_value=1200.0
):
rules = rules_for(Config(), demo=False)
assert rules.carry_seconds == 1200.0
assert rules.budget_reason.endswith(", +20m carried over")
def test_demo_never_carries(self) -> None:
with patch(
"steam_backlog_enforcer._playtime_state.carry_into", return_value=1200.0
):
assert rules_for(Config(), demo=True).carry_seconds == 0.0
class TestHeldToday: class TestHeldToday:

View File

@ -37,7 +37,6 @@ def _spent_state(day_key: str) -> PlaytimeState:
last_credited_key="1", last_credited_key="1",
warned_seconds=[600], warned_seconds=[600],
budget_seconds=1800.0, budget_seconds=1800.0,
carry={"2999-01-01": 120.0},
) )
@ -58,7 +57,7 @@ class TestResetToday:
assert stored is not None assert stored is not None
assert (stored.seconds, stored.blocked_at, stored.per_game) == (0.0, 0.0, {}) assert (stored.seconds, stored.blocked_at, stored.per_game) == (0.0, 0.0, {})
assert (stored.last_credited_key, stored.warned_seconds) == ("", []) assert (stored.last_credited_key, stored.warned_seconds) == ("", [])
assert (stored.budget_seconds, stored.carry) == (1800.0, {"2999-01-01": 120.0}) assert stored.budget_seconds == 1800.0
assert stored.last_tick_at > 0 assert stored.last_tick_at > 0
def test_audit_record_has_the_before_picture(self) -> None: def test_audit_record_has_the_before_picture(self) -> None:

View File

@ -2,6 +2,7 @@
from contextlib import ExitStack from contextlib import ExitStack
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import logging
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@ -196,6 +197,15 @@ class TestWarn:
assert out.warned_seconds == [3600, 1800, 600, 300] assert out.warned_seconds == [3600, 1800, 600, 300]
assert "5 minutes" in mock_notify.call_args.args[1] assert "5 minutes" in mock_notify.call_args.args[1]
def test_a_fired_warning_is_logged(self, caplog: pytest.LogCaptureFixture) -> None:
state = PlaytimeState(seconds=8 * 3600 - 300, warned_seconds=[3600, 1800, 600])
with (
caplog.at_level(logging.INFO),
patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"),
):
_warn(state, _rules())
assert "5 minutes" in caplog.text
def test_no_op_when_nothing_is_due(self) -> None: def test_no_op_when_nothing_is_due(self) -> None:
state = PlaytimeState(seconds=0.0) state = PlaytimeState(seconds=0.0)
with patch( with patch(

View File

@ -176,3 +176,4 @@ class TestTickJournal:
assert record["qualifying"] == [11, 22] assert record["qualifying"] == [11, 22]
assert record["state"] == "engaged" assert record["state"] == "engaged"
assert record["remaining_seconds"] == 28700.0 assert record["remaining_seconds"] == 28700.0
assert record["budget_seconds"] == 28800.0

View File

@ -79,7 +79,6 @@ export function budget(demo: boolean): BudgetSnapshot {
warn_at: [3600, 1800, 600, 300], warn_at: [3600, 1800, 600, 300],
demo, demo,
masked_launchers: ['heroic', 'lutris'], masked_launchers: ['heroic', 'lutris'],
carry_seconds: demo ? 0 : 1200,
}, },
} }
} }

View File

@ -12,13 +12,6 @@ describe('BudgetRulesCard', () => {
expect(screen.getByText(/1h 0m, 30m, 10m, 5m remaining/)).toBeInTheDocument() expect(screen.getByText(/1h 0m, 30m, 10m, 5m remaining/)).toBeInTheDocument()
expect(screen.queryByText(/demo/)).toBeNull() expect(screen.queryByText(/demo/)).toBeNull()
expect(screen.queryByText('Masked now')).toBeNull() expect(screen.queryByText('Masked now')).toBeNull()
expect(screen.queryByText('Carried over')).toBeNull()
})
it('shows time carried over from earlier free days', () => {
render(<BudgetRulesCard rules={makeBudgetRules({ carry_seconds: 1200 })} />)
expect(screen.getByText('Carried over')).toBeInTheDocument()
expect(screen.getByText(/\+20m unspent from earlier free days/)).toBeInTheDocument()
}) })
it('shows the demo mode and masked launchers when they apply', () => { it('shows the demo mode and masked launchers when they apply', () => {

View File

@ -16,12 +16,6 @@ export function BudgetRulesCard({ rules }: Props) {
<dl className="budget-rules-list"> <dl className="budget-rules-list">
<dt>Daily budget</dt> <dt>Daily budget</dt>
<dd>{fmtDuration(rules.budget_seconds)}</dd> <dd>{fmtDuration(rules.budget_seconds)}</dd>
{rules.carry_seconds > 0 && (
<>
<dt>Carried over</dt>
<dd>+{fmtDuration(rules.carry_seconds)} unspent from earlier free days</dd>
</>
)}
<dt>Enforcement</dt> <dt>Enforcement</dt>
<dd>{onOff(rules.enforcement)}</dd> <dd>{onOff(rules.enforcement)}</dd>
<dt>Counts launchers</dt> <dt>Counts launchers</dt>

View File

@ -142,7 +142,6 @@ export function makeBudgetRules(over: Partial<BudgetRules> = {}): BudgetRules {
warn_at: [3600, 1800, 600, 300], warn_at: [3600, 1800, 600, 300],
demo: false, demo: false,
masked_launchers: [], masked_launchers: [],
carry_seconds: 0,
...over, ...over,
} }
} }

View File

@ -173,8 +173,6 @@ export interface BudgetRules {
warn_at: number[] warn_at: number[]
demo: boolean demo: boolean
masked_launchers: string[] masked_launchers: string[]
/** Unspent Fri-Sun time inherited by today; already inside budget_seconds. */
carry_seconds: number
} }
export interface BudgetSnapshot { export interface BudgetSnapshot {