i3blocks: gaming budget block (time left, used/total, unearned bonuses)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

New [gaming_budget] block reads steam-backlog-enforcer's /api/budget every
30s: "🎮 2h00 left · 6h00/8h00 +📖1h". Colours follow the enforcer warn_at
(1h/30m/10m), blocked is red, a dead server shows the cached value as stale.
Unearned bonus sizes come from the enforcer config via jq --slurpfile.

Prunes the oldest evidence artifact to stay under the 60-file cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CD7nSVE2dKvByG8hdSi3gm
This commit is contained in:
Krzysztof Rudnicki 2026-09-26 17:22:54 +02:00
parent 6832805e5d
commit ae99698d5b
6 changed files with 265 additions and 41 deletions

View File

@ -26,6 +26,7 @@ jobs:
run: |
set -euo pipefail
tests=(
test_i3blocks_gaming_budget.sh
test_i3blocks_persist_common.sh
test_setup_dns_blocker.sh
)

View File

@ -0,0 +1,44 @@
{
"intent": "Show today's gaming budget in the i3 bar: time left, used/total, and which bonuses (workout, LeetCode, reading) can still be earned.",
"scope": [
"linux_configuration/i3blocks/gaming_budget.sh (new block), linux_configuration/i3blocks/config",
"linux_configuration/tests/test_i3blocks_gaming_budget.sh, .github/workflows/shell-tests.yml",
"Non-goal: no changes to steam-backlog-enforcer, screen-locker, leetcode-guard or book-guard; the block only reads /api/budget"
],
"changes": [
"New interval=30 block reads steam-backlog-enforcer GET /api/budget with one curl + one jq pass; colours follow the enforcer warn_at (1h yellow, 30m orange, 10m/blocked red)",
"Unearned-bonus sizes come from ~/.config/steam_backlog_enforcer/config.json via jq --slurpfile (never on argv, the file holds secrets), falling back to the enforcer defaults 2h/1h/1h",
"Never blank: dead server shows cached text marked (stale) in orange or 'budget server down' in red; bad state_status, ok:false and unparseable bodies are red and do not overwrite the cache",
"Behaviour test with a PATH-stubbed curl, wired into the shell-tests CI list"
],
"verification": [
{
"command": "~/.config/i3blocks/gaming_budget.sh",
"result": "pass",
"evidence": "Live API: '🎮 2h00 left · 6h00/8h00 +📖1h' / '🎮 2h00' / #50FA7B, matching /api/budget (21600s used of 28800, reading bonus 0)"
},
{
"command": "i3-msg restart (run by the user)",
"result": "pass",
"evidence": "User confirmed the block renders in the bar as intended"
},
{
"command": "bash linux_configuration/tests/test_i3blocks_gaming_budget.sh",
"result": "pass",
"evidence": "All gaming_budget checks passed (thresholds, blocked, overspent, config bonus sizes, stale, down, error states)"
},
{
"command": "bash linux_configuration/tests/test_i3blocks_efficiency.sh",
"result": "pass",
"evidence": "All i3blocks efficiency regression tests passed"
}
],
"risks": [
"Depends on the /api/budget JSON shape (today.*, rules.bonuses.*); a renamed field renders 'unreadable budget' in red rather than a wrong number",
"Bonus fallback sizes duplicate the enforcer defaults; they only matter when config.json leaves the fields unset"
],
"rollback": [
"git revert the commit and remove ~/.config/i3blocks/gaming_budget.sh symlink",
"i3-msg restart and confirm the bar renders without the block"
]
}

View File

@ -1,41 +0,0 @@
{
"intent": "Restore the four test seams in nvidia_config.sh, which were verified and measured but did not reach the previous commit, so the committed suite read and wrote the real system files.",
"scope": [
"linux_configuration/scripts/single_use/fixes/lib/nvidia_config.sh",
"Non-goal: any change to the tests, which were already correct"
],
"changes": [
"Re-applied the XORG_CONF, XORG_CONF_D, PROFILE_FILE and USER_HOME defaults at their assignment sites",
"Grepped the file for all four immediately after writing it, before running anything, because a silently-reverted edit is exactly what caused this"
],
"verification": [
{
"command": "ci-mirror during git push",
"result": "fail",
"evidence": "'lib/tests suite failed: .../fixes/lib/tests/run_all.sh'. ci-mirror runs from a clean checkout of committed files, which is why it caught what the local run could not."
},
{
"command": "bash run_all.sh from a detached worktree at the previous commit",
"result": "fail",
"evidence": "EXIT 1 with 10 failures, including 'configure_xorg: enables RenderAccel (expected \"RenderAccel\" \"true\", got: # NVIDIA configuration with RenderAccel disabled)' -- the suite was reading this machine's real /etc/X11 config, and 'does not re-clone over a checkout' showed a clone path of /home/testuser/pyroveil rather than the tmpdir."
},
{
"command": "grep -n 'XORG_CONF:-|XORG_CONF_D:-|PROFILE_FILE:-|USER_HOME:-' nvidia_config.sh",
"result": "pass",
"evidence": "All four present at lines 26, 27, 60 and 85 after re-applying."
},
{
"command": "shellcheck + shfmt -l on nvidia_config.sh",
"result": "pass",
"evidence": "No findings, zero suppressions, no reformat pending."
}
],
"risks": [
"The previous commit, cb1e8c53, is pushed and briefly had a suite that would write to the real /etc/X11/xorg.conf.d on any machine that ran it un-jailed. Nothing ran it in that window on this machine -- the real 20-nvidia.conf still carries its Jun 2025 mtime.",
"The local run passed against a stale seamed copy in the measurement worktree, which is why a clean-checkout run is now the check that matters before pushing."
],
"rollback": [
"git revert the commit; nvidia_config.sh returns to hardcoded paths and the suite will fail again from a clean checkout",
"Re-run run_all.sh from a detached worktree to confirm the failure mode returns"
]
}

View File

@ -51,6 +51,10 @@ interval=30
color=#FFFFFF
[gaming_budget]
command=~/.config/i3blocks/gaming_budget.sh
interval=30
[screen_locker]
command=~/.config/i3blocks/screen_locker.sh
interval=30

View File

@ -0,0 +1,102 @@
#!/bin/bash
# ============================================================================
# Gaming time budget for i3blocks: time left today, used/total, and which
# bonuses (workout, LeetCode, reading) are still there to be earned.
#
# Reads steam-backlog-enforcer's /api/budget, which already folds in the
# screen-locker, leetcode-guard and book-guard bonuses. Like screen_locker.sh
# it is never blank: a dead server shows the last value marked stale, or red.
# Click opens the enforcer's web UI.
# ============================================================================
set -euo pipefail
readonly API="${GAMING_BUDGET_API:-http://127.0.0.1:8000/api/budget}"
readonly UI='http://127.0.0.1:8000/'
# Only for the *size* of bonuses not yet earned; /api/budget reports earned
# ones as 0. The file holds secrets, so it goes to jq by path, never on argv.
readonly CONFIG="${GAMING_BUDGET_CONFIG:-$HOME/.config/steam_backlog_enforcer/config.json}"
readonly CACHE="${GAMING_BUDGET_CACHE:-${XDG_RUNTIME_DIR:-/tmp}/i3blocks-gaming-budget.cache}"
# Dracula-ish palette, matching screen_locker.sh.
readonly RED='#FF5555'
readonly YELLOW='#F1FA8C'
readonly GREEN='#50FA7B'
readonly ORANGE='#FFB86C'
emit() {
# full_text, short_text, color — the three lines i3blocks reads.
printf '%s\n%s\n%s\n' "$1" "$2" "$3"
}
if [[ ${BLOCK_BUTTON:-0} -ne 0 ]]; then
xdg-open "$UI" >/dev/null 2>&1 &
fi
if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
emit '🎮 no curl/jq' '🎮 ?' "$ORANGE"
exit 0
fi
budget_json=$(curl -fsS --max-time 2 "$API" 2>/dev/null || true)
if [[ -z $budget_json ]]; then
if [[ -r $CACHE ]]; then
emit "$(<"$CACHE") (stale)" '🎮 ?' "$ORANGE"
else
emit '🎮 budget server down' '🎮 ?' "$RED"
fi
exit 0
fi
config_path=/dev/null
[[ -r $CONFIG ]] && config_path=$CONFIG
# One jq pass renders all three lines, so a tick costs curl + jq and no more.
# The first line is "cache" or "nocache": error states must not overwrite the
# last good value that the stale path shows.
# Thresholds mirror the enforcer's own warn_at (1h, 30m, 10m).
rendered=$(jq -r \
--slurpfile cfg "$config_path" \
--arg red "$RED" --arg orange "$ORANGE" --arg yellow "$YELLOW" --arg green "$GREEN" '
def hm: (. / 60 | round) as $m
| "\($m / 60 | floor)h\($m % 60 | tostring | if length < 2 then "0" + . else . end)";
def bonus_hm: hm | sub("h00$"; "h");
($cfg[0] // {}) as $c
| if .state_status != "ok" then ["nocache", "🎮 state \(.state_status)", "🎮 !", $red]
elif .ok != true then ["nocache", "🎮 budget error", "🎮 !", $red]
else
.today as $t
| .rules.bonuses as $b
| ([$t.seconds_remaining, 0] | max) as $left
| "\($t.seconds_used | hm)/\($t.budget_seconds | hm)" as $ratio
# Unearned bonuses: [earned, configured size (enforcer default), icon].
| [ [$b.workout, ($c.workout_bonus_seconds // 7200), "💪"],
[$b.leetcode, ($c.leetcode_bonus_seconds // 3600), "🧩"],
[$b.reading, ($c.reading_bonus_seconds // 3600), "📖"] ]
| map(select((.[0] // 0) == 0 and .[1] > 0) | "+\(.[2])\(.[1] | bonus_hm)")
| (if length > 0 then " " + join(" ") else "" end) as $todo
| if $t.blocked or $left <= 0 then
["cache", "🎮 BLOCKED · \($ratio)\($todo)", "🎮 BLOCKED", $red]
else
["cache", "🎮 \($left | hm) left · \($ratio)\($todo)", "🎮 \($left | hm)",
(if $left <= 600 then $red
elif $left <= 1800 then $orange
elif $left <= 3600 then $yellow
else $green end)]
end
end
| .[]' <<<"$budget_json" 2>/dev/null) || {
emit '🎮 unreadable budget' '🎮 !' "$RED"
exit 0
}
{
read -r cache_flag
read -r full_text
read -r short_text
read -r color
} <<<"$rendered"
[[ $cache_flag == 'cache' ]] && printf '%s' "$full_text" >"$CACHE"
emit "$full_text" "$short_text" "$color"

View File

@ -0,0 +1,114 @@
#!/bin/bash
# Behaviour tests for the i3blocks gaming_budget block: text, colour
# thresholds, unearned-bonus suffix and every failure state. curl is stubbed on
# PATH to serve a fixture file, so nothing talks to the real enforcer.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
BLOCK="$REPO_DIR/i3blocks/gaming_budget.sh"
TMP_DIR=$(mktemp -d)
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
assert_eq() {
local expected=$1
local actual=$2
local context=$3
if [[ "$expected" != "$actual" ]]; then
fail "$context (expected '$expected', actual '$actual')"
fi
}
printf '{}' | jq -e . >/dev/null 2>&1 ||
fail 'a working jq is required for the gaming_budget tests (pacman -S jq)'
# Stub curl: print $FIXTURE, or fail like `curl -f` on a dead server.
BIN_DIR="$TMP_DIR/bin"
mkdir -p "$BIN_DIR"
cat >"$BIN_DIR/curl" <<'STUB'
#!/bin/bash
[[ -r ${FIXTURE:-} ]] || exit 7
cat "$FIXTURE"
STUB
chmod +x "$BIN_DIR/curl"
FIXTURE="$TMP_DIR/budget.json"
CACHE_FILE="$TMP_DIR/cache"
CONFIG_FILE="$TMP_DIR/config.json"
# write_budget USED BUDGET REMAINING BLOCKED WORKOUT LEETCODE READING
write_budget() {
printf '{"ok":true,"state_status":"ok","today":{"seconds_used":%s,"budget_seconds":%s,"seconds_remaining":%s,"blocked":%s},"rules":{"bonuses":{"base":18000,"workout":%s,"leetcode":%s,"reading":%s}}}\n' \
"$@" >"$FIXTURE"
}
run_block() {
PATH="$BIN_DIR:$PATH" FIXTURE="$FIXTURE" GAMING_BUDGET_CACHE="$CACHE_FILE" \
GAMING_BUDGET_CONFIG="$CONFIG_FILE" bash "$BLOCK"
}
line() {
run_block | sed -n "${1}p"
}
printf 'Checking the normal day: time left, used/total, missing bonus...\n'
write_budget 21600.3 28800 7199.7 false 7200 3600 0
assert_eq '🎮 2h00 left · 6h00/8h00 +📖1h' "$(line 1)" 'full text'
assert_eq '🎮 2h00' "$(line 2)" 'short text'
assert_eq '#50FA7B' "$(line 3)" 'green above 1h'
printf 'Checking colour thresholds follow the enforcer warn_at...\n'
write_budget 25800 28800 3000 false 7200 3600 3600
assert_eq '#F1FA8C' "$(line 3)" 'yellow at <=1h'
assert_eq '🎮 0h50 left · 7h10/8h00' "$(line 1)" 'no suffix when every bonus is earned'
write_budget 27600 28800 1200 false 7200 3600 3600
assert_eq '#FFB86C' "$(line 3)" 'orange at <=30m'
write_budget 28500 28800 300 false 7200 3600 3600
assert_eq '#FF5555' "$(line 3)" 'red at <=10m'
printf 'Checking the blocked state...\n'
write_budget 18000 18000 0 true 0 0 0
assert_eq '🎮 BLOCKED · 5h00/5h00 +💪2h +🧩1h +📖1h' "$(line 1)" 'blocked full text lists what can still be earned'
assert_eq '🎮 BLOCKED' "$(line 2)" 'blocked short text'
assert_eq '#FF5555' "$(line 3)" 'blocked is red'
write_budget 18100 18000 -100 false 7200 3600 3600
assert_eq '🎮 BLOCKED' "$(line 2)" 'overspent counts as blocked even before the flag flips'
printf 'Checking bonus sizes come from the enforcer config...\n'
printf '{"workout_bonus_seconds":5400,"leetcode_bonus_seconds":0}\n' >"$CONFIG_FILE"
write_budget 0 18000 18000 false 0 0 0
assert_eq '🎮 5h00 left · 0h00/5h00 +💪1h30 +📖1h' "$(line 1)" 'configured sizes, zero-size bonus omitted'
rm -f "$CONFIG_FILE"
printf 'Checking a dead server shows the cached value as stale...\n'
write_budget 21600 28800 7200 false 7200 3600 0
run_block >/dev/null
rm -f "$FIXTURE"
assert_eq '🎮 2h00 left · 6h00/8h00 +📖1h (stale)' "$(line 1)" 'stale text'
assert_eq '#FFB86C' "$(line 3)" 'stale is orange'
rm -f "$CACHE_FILE"
assert_eq '🎮 budget server down' "$(line 1)" 'no cache: server down'
assert_eq '#FF5555' "$(line 3)" 'server down is red'
printf 'Checking error states are red and never overwrite the cache...\n'
printf 'kept' >"$CACHE_FILE"
printf '{"ok":false,"state_status":"corrupt"}\n' >"$FIXTURE"
assert_eq '🎮 state corrupt' "$(line 1)" 'bad state_status'
assert_eq '#FF5555' "$(line 3)" 'bad state is red'
printf '{"ok":false,"state_status":"ok"}\n' >"$FIXTURE"
assert_eq '🎮 budget error' "$(line 1)" 'ok:false'
printf 'not json' >"$FIXTURE"
assert_eq '🎮 unreadable budget' "$(line 1)" 'garbage body'
assert_eq 'kept' "$(<"$CACHE_FILE")" 'error states must leave the cache alone'
printf 'All gaming_budget checks passed.\n'