steam-backlog-enforcer/steam_backlog_enforcer/_budget_games.py
Krzysztof kuhy Rudnicki 1faf211767 Show which games make up the gaming budget, and bill osu!lazer
The budget tab answered "how much did I play" but not "what did I play":
playtime_state.json and playtime_history.json each stored one aggregate
seconds figure per gaming day, so the 14-day chart was a row of featureless
bars. Separately, qualifying_pids had exactly two sources — the SteamAppId
env var and a hardcoded 16-launcher frozenset — so osu!lazer, which sets no
SteamAppId and matches no launcher name, was never billed at all.

qualifying_pids now returns pid -> attribution key instead of a bare pid set.
The app id was already in hand and thrown away on its first line; it now
survives into PlaytimeState.per_game, alongside a user-editable
counted_processes list in config.json seeded with osu!lazer.

Two things the parts/whole relationship forces:

  - per_game only ever sums to AT MOST seconds, never equals it. backdate
    refunds the idle grace on every engaged-to-paused edge, and
    _state_or_recover synthesises a full budget with no breakdown. The
    refund is debited from last_credited_key, and the remainder is rendered
    as "Unattributed" rather than stored — one path then covers legacy days,
    focus-probe failures and drift alike.

  - counted_processes is kept apart from LAUNCHER_PROCESS_NAMES. That
    frozenset is also the total-block kill list, and it routes through
    kill_and_uninstall_launchers, which pacman -R's the owning package.
    Counted entries are killed on total block, never uninstalled.

Both storage formats stay at schema 1: load_state already filters to
dataclass fields, so a record without per_game migrates for free, and the
history reader has to accept both a bare float and an object regardless, so
a version bump would distinguish nothing.

Chart segments use the shared design system's categorical ramp (--cat-1..6),
capped at six games plus "Other" because a seventh distinguishable hue does
not exist under simulated CVD. The red over-budget fill is gone: colour now
encodes which game, and the dashed budget line alone says over or under.

Snapshot persistence moves to _snapshot.py to keep config.py under the
250-line cap. Three modules bound config paths at import time, which
bypasses the conftest redirect — they now reach them through the module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
2026-08-29 18:32:03 +02:00

202 lines
6.6 KiB
Python

"""Rolling billed time up into the per-game slices the UI plots.
Two rules live here rather than in the chart component, so that the HTTP
payload and the MCP payload cannot disagree with what is drawn:
**Unattributed is computed, never stored.** ``per_game`` only ever sums to at
most the day's total — a tick the focus probe could not pin to one game still
bills, ``backdate`` refunds scale the parts, and a fail-closed day has a full
budget with no breakdown at all. The shortfall is surfaced as its own slice
instead of being hidden or charged to a guess. Days written before per-game
attribution existed have no breakdown, so their whole bar is this slice.
**The window is capped at six games plus "Other".** Six is not arbitrary: it is
the size of the shared categorical ramp, which cannot be extended to seven
distinguishable hues under simulated colour-vision deficiency.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Final
from steam_backlog_enforcer._attribution_labels import label_for
from steam_backlog_enforcer._playtime_history import load_labels
if TYPE_CHECKING:
from steam_backlog_enforcer._playtime_history import HistoryDay
from steam_backlog_enforcer._playtime_state import PlaytimeState
UNATTRIBUTED_KEY: Final = "unattributed"
OTHER_KEY: Final = "other"
# Matches the categorical ramp's six hues; see the module docstring.
_TOP_N: Final = 6
def _label(key: str, stored: dict[str, str]) -> str:
"""Return a display label for *key*.
The stored map is preferred because it was written while the game was
installed — an uninstalled game's appmanifest is gone, so a live lookup
could not recover its name. The live lookup is the fallback for *today*,
whose keys have not reached the history file yet.
Args:
key: Attribution key.
stored: Labels persisted alongside the history.
Returns:
The display label.
"""
if key == UNATTRIBUTED_KEY:
return "Unattributed"
if key == OTHER_KEY:
return "Other"
return stored.get(key) or label_for(key)
def _residual(total: float, games: dict[str, float]) -> float:
"""Return the part of *total* that no key accounts for.
Floored at zero: proportional refunds make the parts drift by fractions of
a second, and a negative segment would render as an inverted bar.
Args:
total: Seconds billed that day.
games: Attribution key to seconds.
Returns:
The unattributed remainder.
"""
return max(0.0, total - sum(games.values()))
def today_games(state: PlaytimeState) -> list[dict[str, Any]]:
"""Return every game billed today, largest first, with the remainder last.
Uncapped on purpose: the fortnight chart has six colours to spend, but the
"Today" list is text and can show everything.
Args:
state: Loaded accounting state.
Returns:
Serialisable slices, each with ``key``, ``label``, ``seconds`` and
``fraction`` of the day's billed total.
"""
stored = load_labels()
slices = sorted(state.per_game.items(), key=lambda item: -item[1])
residual = _residual(state.seconds, state.per_game)
if residual > 0:
slices.append((UNATTRIBUTED_KEY, residual))
total = state.seconds or 1.0
return [
{
"key": key,
"label": _label(key, stored),
"seconds": round(seconds, 1),
"fraction": round(seconds / total, 4),
}
for key, seconds in slices
if seconds > 0
]
def _window_top_keys(days: list[HistoryDay]) -> list[str]:
"""Return the keys worth their own colour across the whole window.
Ranked by total across the window rather than per day, so a game keeps the
same colour in every bar it appears in.
Args:
days: The days being plotted.
Returns:
At most ``_TOP_N`` keys, largest total first.
"""
totals: dict[str, float] = {}
for day in days:
for key, seconds in day.games.items():
totals[key] = totals.get(key, 0.0) + seconds
ranked = sorted(totals.items(), key=lambda item: (-item[1], item[0]))
return [key for key, _ in ranked[:_TOP_N]]
def _day_segments(day: HistoryDay, top: list[str]) -> dict[str, float]:
"""Fold one day's breakdown into the capped key set.
Args:
day: The day to fold.
top: Keys that keep their own identity.
Returns:
Mapping of rendered key to seconds.
"""
segments: dict[str, float] = {}
for key, seconds in day.games.items():
rendered = key if key in top else OTHER_KEY
segments[rendered] = segments.get(rendered, 0.0) + seconds
residual = _residual(day.seconds, day.games)
if residual > 0:
segments[UNATTRIBUTED_KEY] = segments.get(UNATTRIBUTED_KEY, 0.0) + residual
return segments
def history_view(
days: list[HistoryDay],
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
"""Return the plottable days and the legend they share.
Segment order is the legend order, identical for every day, so a stacked
bar's bands line up across the window and each key keeps one colour.
Args:
days: Recorded days, oldest first.
Returns:
The days and the legend, in render order.
"""
top = _window_top_keys(days)
folded = [(day, _day_segments(day, top)) for day in days]
def present(key: str) -> bool:
return any(key in seg for _, seg in folded)
# The two sentinels always trail the real games, in that order.
order = [key for key in top if present(key)]
order.extend(key for key in (OTHER_KEY, UNATTRIBUTED_KEY) if present(key))
stored = load_labels()
legend = [{"key": key, "label": _label(key, stored)} for key in order]
plotted = [
{
"day": day.day,
"seconds": round(day.seconds, 1),
"segments": [
{"key": key, "seconds": round(segments[key], 1)}
for key in order
if segments.get(key, 0.0) > 0
],
}
for day, segments in folded
]
return plotted, legend
def billing_label(key: str) -> str:
"""Return the display label for the key currently being credited.
Distinct from the backlog *assignment* the session block already reports:
that is the game the enforcer told you to play, this is the one the budget
is actually charging. They can legitimately differ — a counted non-Steam
game is never the assignment.
Args:
key: Attribution key, or ``""`` when nothing has billed yet.
Returns:
The label, or ``""``.
"""
return _label(key, load_labels()) if key else ""