mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
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
202 lines
6.6 KiB
Python
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 ""
|