diff --git a/DOCS-ctl-socket.md b/DOCS-ctl-socket.md
index 84331f1..fca02ac 100644
--- a/DOCS-ctl-socket.md
+++ b/DOCS-ctl-socket.md
@@ -36,8 +36,8 @@ server: `_ctl_client.py` (results in `_ctl_types.py`, errors in `_ctl_protocol.p
## Gaming reset
`_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`,
-`budget_seconds`; the old CLI path dropped both), and writes a `manual_adjustment`
+attribution, re-arms warnings, releases the mounts, keeps earned time
+(`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
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.
@@ -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 |
| 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 |
-| 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 |
| Total block | yes | lock file, guard-lib |
| Armed gaming reset | no | memory only: arm again (fails closed) |
diff --git a/TODO-log-carry-in-budget-line.md b/TODO-log-carry-in-budget-line.md
deleted file mode 100644
index 47fc91c..0000000
--- a/TODO-log-carry-in-budget-line.md
+++ /dev/null
@@ -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
diff --git a/steam_backlog_enforcer/_budget_resolve.py b/steam_backlog_enforcer/_budget_resolve.py
index 03e59a7..5a5950f 100644
--- a/steam_backlog_enforcer/_budget_resolve.py
+++ b/steam_backlog_enforcer/_budget_resolve.py
@@ -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.
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
-``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
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_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).
# An earner registered later without an entry here gets a generic phrase.
@@ -173,7 +175,11 @@ def resolve_budget(config: Config) -> BudgetResolution:
_describe(t.earner.name, t.earner.label, t.answer) for t in day.terms
)
reason = f"{total / _SECONDS_PER_HOUR:.1f}h: {parts}"
- logger.info("Gaming budget %s", reason)
+ # 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)
return BudgetResolution(
seconds=total,
base_seconds=day.base.gaming_minutes * _SECONDS_PER_MINUTE,
diff --git a/steam_backlog_enforcer/_budget_view.py b/steam_backlog_enforcer/_budget_view.py
index 4708f52..00094a8 100644
--- a/steam_backlog_enforcer/_budget_view.py
+++ b/steam_backlog_enforcer/_budget_view.py
@@ -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
# enforcing. See _budget_resolve's "one seam".
"budget_reason": rules.budget_reason,
- # Unspent Fri-Sun time inherited by today; already inside budget_seconds.
- "carry_seconds": rules.carry_seconds,
"bonuses": {
"base": rules.base_seconds,
"workout": rules.workout_seconds,
diff --git a/steam_backlog_enforcer/_gaming_days.py b/steam_backlog_enforcer/_gaming_days.py
index 3e9270f..2cf4358 100644
--- a/steam_backlog_enforcer/_gaming_days.py
+++ b/steam_backlog_enforcer/_gaming_days.py
@@ -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
the day they started in.
-**Free-day carry-over.** Friday to Monday is one block. Whatever a Friday,
-Saturday or Sunday leaves unspent is split evenly, with no strings attached,
-across the days still left in that block:
-
- Fri leftover -> Sat, Sun, Mon (a third each)
- 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.
+There is no carry-over: unspent time is dropped at 06:00, every day. The
+Friday-Monday carry ledger was removed on 2026-10-18, after a Sunday ran
+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
+high-water mark.
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.
@@ -26,7 +15,7 @@ nothing from the playtime modules and cannot close an import cycle.
from __future__ import annotations
-from datetime import date, timedelta
+from datetime import timedelta
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
@@ -37,10 +26,6 @@ if TYPE_CHECKING:
# The gaming day starts here, local time. 05:59 belongs to the previous day.
_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:
"""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()
-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:
"""Return the budget already granted earlier in *now*'s gaming day.
diff --git a/steam_backlog_enforcer/_gaming_reset.py b/steam_backlog_enforcer/_gaming_reset.py
index f41b9d8..cf345d6 100644
--- a/steam_backlog_enforcer/_gaming_reset.py
+++ b/steam_backlog_enforcer/_gaming_reset.py
@@ -56,10 +56,9 @@ class ResetOutcome:
def reset_today(*, source: str) -> ResetOutcome:
"""Zero today's billed time, drop the block and record the adjustment.
- The earned-time bookkeeping (``carry`` from earlier free days, the day's
- ``budget_seconds`` high-water mark) is kept: resetting spent time must not
- also revoke time already earned. Warnings re-arm because ``seconds`` is
- back at zero.
+ The day's ``budget_seconds`` high-water mark is kept: resetting spent
+ time must not also revoke time already earned. Warnings re-arm because
+ ``seconds`` is back at zero.
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
diff --git a/steam_backlog_enforcer/_mcp_gaming_time.py b/steam_backlog_enforcer/_mcp_gaming_time.py
index e2b83d8..04ac19d 100644
--- a/steam_backlog_enforcer/_mcp_gaming_time.py
+++ b/steam_backlog_enforcer/_mcp_gaming_time.py
@@ -56,9 +56,8 @@ def get_gaming_time() -> dict[str, Any]:
"seconds_used": today["seconds_used"],
"budget_seconds": today["budget_seconds"],
"seconds_remaining": today["seconds_remaining"],
- # What earned budget_seconds, Friday-Monday carry-over included.
+ # What earned budget_seconds.
"budget_reason": rules["budget_reason"],
- "carry_seconds": rules["carry_seconds"],
"next_warning_seconds": today["next_warning_seconds"],
"blocked": today["blocked"],
"enforcement": rules["enforcement"],
diff --git a/steam_backlog_enforcer/_playtime_budget.py b/steam_backlog_enforcer/_playtime_budget.py
index 581fce6..c7c9312 100644
--- a/steam_backlog_enforcer/_playtime_budget.py
+++ b/steam_backlog_enforcer/_playtime_budget.py
@@ -11,7 +11,6 @@ from dataclasses import replace
import logging
from typing import TYPE_CHECKING
-from steam_backlog_enforcer._gaming_days import pass_on
from steam_backlog_enforcer._playtime_state import (
_DELTA_CLAMP_FACTOR,
PlaytimeRules,
@@ -80,9 +79,8 @@ def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState:
"""Reset *state* if the gaming day has changed.
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
- ledger crosses it with the outgoing day's leftover added (see
- ``_gaming_days``).
+ new day measures a sane delta rather than restarting from zero. Nothing else
+ does: the outgoing day's unspent time is dropped.
Args:
state: Current accounting state.
@@ -93,17 +91,7 @@ def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState:
"""
if state.day_key == day_key:
return state
- return PlaytimeState(
- 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,
- ),
- )
+ return PlaytimeState(day_key=day_key, last_tick_at=state.last_tick_at)
def pending_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None:
diff --git a/steam_backlog_enforcer/_playtime_cutoff.py b/steam_backlog_enforcer/_playtime_cutoff.py
index 61a9cc1..d86fbb0 100644
--- a/steam_backlog_enforcer/_playtime_cutoff.py
+++ b/steam_backlog_enforcer/_playtime_cutoff.py
@@ -65,10 +65,14 @@ def _warn(state: PlaytimeState, rules: PlaytimeRules) -> PlaytimeState:
if threshold is None:
return state
- notify_desktop_user(
- "Gaming time running out",
- f"{_humanise(threshold)} of today's gaming budget left.",
+ left = f"{_humanise(threshold)} of today's gaming budget left."
+ logger.info(
+ "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])
diff --git a/steam_backlog_enforcer/_playtime_legacy.py b/steam_backlog_enforcer/_playtime_legacy.py
new file mode 100644
index 0000000..3f544fd
--- /dev/null
+++ b/steam_backlog_enforcer/_playtime_legacy.py
@@ -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))
diff --git a/steam_backlog_enforcer/_playtime_log.py b/steam_backlog_enforcer/_playtime_log.py
index 289d89a..fcb6018 100644
--- a/steam_backlog_enforcer/_playtime_log.py
+++ b/steam_backlog_enforcer/_playtime_log.py
@@ -223,5 +223,6 @@ def _snapshot(
"qualifying": sorted(qualifying),
"day_key": state.day_key,
"billed_seconds": round(state.seconds, 3),
+ "budget_seconds": round(rules.budget_seconds, 3),
"remaining_seconds": round(rules.budget_seconds - state.seconds, 3),
}
diff --git a/steam_backlog_enforcer/_playtime_state.py b/steam_backlog_enforcer/_playtime_state.py
index 445023f..a3a6c00 100644
--- a/steam_backlog_enforcer/_playtime_state.py
+++ b/steam_backlog_enforcer/_playtime_state.py
@@ -1,6 +1,6 @@
"""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
survive a restart mid-session.
"""
@@ -14,7 +14,8 @@ import logging
from typing import TYPE_CHECKING
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 (
_try_set_immutable,
unlock_for_write,
@@ -95,12 +96,10 @@ class PlaytimeState:
warned_seconds: list[int] = field(default_factory=list)
"""Warning thresholds already fired during ``day_key``."""
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:
"""Whether the cutoff has engaged for this gaming day.
@@ -133,7 +132,6 @@ class PlaytimeRules:
reading_seconds: float = 0.0
earned_seconds: dict[str, float] = field(default_factory=dict)
budget_reason: str = ""
- carry_seconds: float = 0.0
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
# _budget_view (Config per request) can never report different budgets.
resolved = None if demo else resolve_budget(config)
- # Carry sits on top of the 8h cap (earned, not spent: no strings). The
- # day never re-prices below what it already granted -- see held_today.
+ # The day never re-prices below what it already granted -- see held_today.
now = datetime.now().astimezone()
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 + carry
+ live = 0.0 if resolved is None else resolved.seconds
held = held_today(stored, now)
held_note = f"; held at {held / 3600:.1f}h (earned earlier today)"
return PlaytimeRules(
@@ -178,11 +174,8 @@ def rules_for(config: Config, *, demo: bool) -> PlaytimeRules:
budget_reason=(
"demo run"
if resolved is None
- else resolved.reason
- + (f", +{carry / 60:.0f}m carried over" if carry > 0 else "")
- + (held_note if held > live else "")
+ else resolved.reason + (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)
return None
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:
diff --git a/steam_backlog_enforcer/tests/test_budget_resolve.py b/steam_backlog_enforcer/tests/test_budget_resolve.py
index 8fa0d92..dbbac7c 100644
--- a/steam_backlog_enforcer/tests/test_budget_resolve.py
+++ b/steam_backlog_enforcer/tests/test_budget_resolve.py
@@ -15,7 +15,7 @@ from unittest.mock import patch
import earned_time
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.config import Config
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()
_leetcode_bonus.reset_cache()
+ _budget_resolve._last_logged.clear()
yield
_workout_budget.reset_cache()
_leetcode_bonus.reset_cache()
+ _budget_resolve._last_logged.clear()
@pytest.fixture(autouse=True)
@@ -202,3 +204,20 @@ class TestTheExplanation:
assert "no counted workout" in caplog.text
assert "no LeetCode solve recorded" 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
diff --git a/steam_backlog_enforcer/tests/test_budget_view.py b/steam_backlog_enforcer/tests/test_budget_view.py
index f2bc34c..75487ae 100644
--- a/steam_backlog_enforcer/tests/test_budget_view.py
+++ b/steam_backlog_enforcer/tests/test_budget_view.py
@@ -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
# 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.
-# The carry is pinned for the same reason: conftest's redirects are not active
-# at import either, so rules_for would read the LIVE playtime_state.json and
-# fold whatever Fri-Mon carry it holds today into RULES.
+# The held budget is pinned for the same reason: conftest's redirects are not
+# active at import either, so rules_for would read the LIVE playtime_state.json
+# and hold RULES at whatever high-water mark it records today.
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.resolve_budget",
@@ -224,7 +223,6 @@ class TestBuildBudgetSnapshot:
"demo",
"masked_launchers",
"budget_reason",
- "carry_seconds",
"bonuses",
"earners",
}
diff --git a/steam_backlog_enforcer/tests/test_gaming_days.py b/steam_backlog_enforcer/tests/test_gaming_days.py
index c32d909..f9d9eb9 100644
--- a/steam_backlog_enforcer/tests/test_gaming_days.py
+++ b/steam_backlog_enforcer/tests/test_gaming_days.py
@@ -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 dataclasses import replace
+from dataclasses import fields
from datetime import datetime, timedelta, timezone
+import json
from unittest.mock import patch
-from steam_backlog_enforcer._gaming_days import (
- carry_due,
- carry_into,
- held_today,
- pass_on,
-)
+import pytest
+
+from steam_backlog_enforcer._gaming_days import held_today
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
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))
-def _leftover(day: str, *, budget: float, spent: float, new_day: str) -> dict:
- return pass_on({}, outgoing_day=day, budget=budget, spent=spent, new_day=new_day)
+class TestNoCarryOver:
+ """Unspent time never moves to a later day (carry-over dropped 2026-10-18)."""
-
-class TestPassOn:
- """Who inherits a day's unspent budget."""
-
- 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
+ def test_a_saturday_leftover_is_not_passed_on(self) -> None:
+ sun = roll_over(
+ PlaytimeState(day_key=SAT, seconds=HOUR, budget_seconds=8 * HOUR),
+ day_key=SUN,
)
- 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:
- owed = _leftover(FRI, budget=3 * HOUR, spent=0.0, new_day=SUN)
- assert owed == {SUN: HOUR, MON: HOUR}
+ def test_rules_have_no_carry(self) -> None:
+ assert "carry_seconds" not in {f.name for f in fields(PlaytimeRules)}
- def test_shares_accumulate_with_earlier_ones(self) -> None:
- owed = pass_on(
- {SUN: 600.0, MON: 600.0},
- outgoing_day=SAT,
- budget=HOUR,
- spent=0.0,
- new_day=SUN,
- )
- assert owed == {SUN: 2400.0, MON: 2400.0}
+ def test_a_legacy_carry_is_taken_out_of_todays_high_water(self) -> None:
+ # The live file on 2026-10-18 held 6.0h earned + 9994s carried into the
+ # day's high-water mark; held_today would otherwise keep paying it out.
+ legacy = {
+ "schema_version": 1,
+ "day_key": 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)
-
-class TestCarryDue:
- """Today's share, whether or not the stored record has rolled over."""
-
- def test_same_day_reads_the_ledger(self) -> None:
- due = carry_due({SAT: 60.0}, stored_day=SAT, budget=0.0, spent=0.0, today=SAT)
- assert due == 60.0
-
- def test_stale_record_is_projected_forward(self) -> None:
- due = carry_due({}, stored_day=FRI, budget=4 * HOUR, spent=HOUR, today=SAT)
- assert due == 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
+ @pytest.mark.parametrize(
+ "carry", [{MON: 9994.0}, {SUN: "9994"}, {SUN: 0.0}, [9994.0]]
+ )
+ def test_a_ledger_owing_today_nothing_leaves_the_budget(
+ self, carry: object
+ ) -> None:
+ legacy = {
+ "schema_version": 1,
+ "day_key": SUN,
+ "budget_seconds": 6 * HOUR,
+ "carry": carry,
+ }
+ 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
class TestHeldToday:
diff --git a/steam_backlog_enforcer/tests/test_gaming_reset.py b/steam_backlog_enforcer/tests/test_gaming_reset.py
index 8d75892..351a8f6 100644
--- a/steam_backlog_enforcer/tests/test_gaming_reset.py
+++ b/steam_backlog_enforcer/tests/test_gaming_reset.py
@@ -37,7 +37,6 @@ def _spent_state(day_key: str) -> PlaytimeState:
last_credited_key="1",
warned_seconds=[600],
budget_seconds=1800.0,
- carry={"2999-01-01": 120.0},
)
@@ -58,7 +57,7 @@ class TestResetToday:
assert stored is not None
assert (stored.seconds, stored.blocked_at, stored.per_game) == (0.0, 0.0, {})
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
def test_audit_record_has_the_before_picture(self) -> None:
diff --git a/steam_backlog_enforcer/tests/test_playtime_cutoff.py b/steam_backlog_enforcer/tests/test_playtime_cutoff.py
index a3fbcd4..34e7bd6 100644
--- a/steam_backlog_enforcer/tests/test_playtime_cutoff.py
+++ b/steam_backlog_enforcer/tests/test_playtime_cutoff.py
@@ -2,6 +2,7 @@
from contextlib import ExitStack
from datetime import datetime, timedelta, timezone
+import logging
from unittest.mock import MagicMock, patch
import pytest
@@ -196,6 +197,15 @@ class TestWarn:
assert out.warned_seconds == [3600, 1800, 600, 300]
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:
state = PlaytimeState(seconds=0.0)
with patch(
diff --git a/steam_backlog_enforcer/tests/test_playtime_log.py b/steam_backlog_enforcer/tests/test_playtime_log.py
index 7700741..a11c405 100644
--- a/steam_backlog_enforcer/tests/test_playtime_log.py
+++ b/steam_backlog_enforcer/tests/test_playtime_log.py
@@ -176,3 +176,4 @@ class TestTickJournal:
assert record["qualifying"] == [11, 22]
assert record["state"] == "engaged"
assert record["remaining_seconds"] == 28700.0
+ assert record["budget_seconds"] == 28800.0
diff --git a/web/mock/budget.ts b/web/mock/budget.ts
index 1647343..4456715 100644
--- a/web/mock/budget.ts
+++ b/web/mock/budget.ts
@@ -79,7 +79,6 @@ export function budget(demo: boolean): BudgetSnapshot {
warn_at: [3600, 1800, 600, 300],
demo,
masked_launchers: ['heroic', 'lutris'],
- carry_seconds: demo ? 0 : 1200,
},
}
}
diff --git a/web/src/components/BudgetRulesCard.test.tsx b/web/src/components/BudgetRulesCard.test.tsx
index d0143cf..28528e7 100644
--- a/web/src/components/BudgetRulesCard.test.tsx
+++ b/web/src/components/BudgetRulesCard.test.tsx
@@ -12,13 +12,6 @@ describe('BudgetRulesCard', () => {
expect(screen.getByText(/1h 0m, 30m, 10m, 5m remaining/)).toBeInTheDocument()
expect(screen.queryByText(/demo/)).toBeNull()
expect(screen.queryByText('Masked now')).toBeNull()
- expect(screen.queryByText('Carried over')).toBeNull()
- })
-
- it('shows time carried over from earlier free days', () => {
- render(