From e190d9a4bd0f73879e0893ef127bd869cfab46fd Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 19 Oct 2026 12:27:15 +0200 Subject: [PATCH] i3blocks: shadow-week leisure block (leisure_shadow.sh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders daily-limits' `leisure_shadow` cache (contract C5): `๐Ÿ›‹ SHADOW 3h12 left ยท 1h48 used`, yellow under 30 min, red `WOULD BLOCK` at 0, never urgent; `โฑ? no data` while the enforcer has no C4 fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N --- .github/workflows/shell-tests.yml | 1 + .../i3blocks-leisure-shadow-2026-10-19.json | 15 ++ .../i3blocks-leisure-shadow-2026-10-19.json | 37 +++++ linux_configuration/i3blocks/config | 5 + .../i3blocks/leisure_shadow.sh | 92 ++++++++++++ .../tests/test_i3blocks_leisure_shadow.sh | 136 ++++++++++++++++++ 6 files changed, 286 insertions(+) create mode 100644 docs/superpowers/contracts/i3blocks-leisure-shadow-2026-10-19.json create mode 100644 docs/superpowers/evidence/i3blocks-leisure-shadow-2026-10-19.json create mode 100755 linux_configuration/i3blocks/leisure_shadow.sh create mode 100755 linux_configuration/tests/test_i3blocks_leisure_shadow.sh diff --git a/.github/workflows/shell-tests.yml b/.github/workflows/shell-tests.yml index ac0af9da..2a5cb9ee 100644 --- a/.github/workflows/shell-tests.yml +++ b/.github/workflows/shell-tests.yml @@ -31,6 +31,7 @@ jobs: set -euo pipefail tests=( test_i3blocks_gaming_budget.sh + test_i3blocks_leisure_shadow.sh test_i3blocks_persist_common.sh test_i3blocks_shutdown_hint.sh test_setup_dns_blocker.sh diff --git a/docs/superpowers/contracts/i3blocks-leisure-shadow-2026-10-19.json b/docs/superpowers/contracts/i3blocks-leisure-shadow-2026-10-19.json new file mode 100644 index 00000000..9ce27d20 --- /dev/null +++ b/docs/superpowers/contracts/i3blocks-leisure-shadow-2026-10-19.json @@ -0,0 +1,15 @@ +{ + "title": "i3blocks: shadow-week leisure block (leisure_shadow.sh)", + "objective": "Render daily-limits' `leisure_shadow` cache (contract C5) as `๐Ÿ›‹ SHADOW left ยท used`, yellow under 30 min, red `๐Ÿ›‹ SHADOW WOULD BLOCK ยท used` at 0, never urgent.", + "acceptance_criteria": [ + "bash linux_configuration/tests/test_i3blocks_leisure_shadow.sh passes", + "null/absent numbers render `๐Ÿ›‹ SHADOW โฑ? no data`, never 0", + "the block always exits 0 and prints exactly three lines", + "[leisure_shadow] sits right after [gaming_budget] in linux_configuration/i3blocks/config" + ], + "out_of_scope": [ + "Renaming the gaming block or its labels (Phase 2)", + "Any accounting: the numbers come from steam-backlog-enforcer via daily-limits" + ], + "verifier": "the test script above, shellcheck, and the block run against a forced fixture cache" +} diff --git a/docs/superpowers/evidence/i3blocks-leisure-shadow-2026-10-19.json b/docs/superpowers/evidence/i3blocks-leisure-shadow-2026-10-19.json new file mode 100644 index 00000000..52500283 --- /dev/null +++ b/docs/superpowers/evidence/i3blocks-leisure-shadow-2026-10-19.json @@ -0,0 +1,37 @@ +{ + "intent": "Show the shadow week's would-be leisure budget on i3bar: `๐Ÿ›‹ SHADOW 3h12 left ยท 1h48 used`, yellow under 30 min, red `WOULD BLOCK` at 0, never urgent/blinking, honest `โฑ? no data` while the enforcer's C4 fields are absent.", + "scope": [ + "linux_configuration/i3blocks/leisure_shadow.sh (new block), its [leisure_shadow] config entry right after [gaming_budget]", + "linux_configuration/tests/test_i3blocks_leisure_shadow.sh + shell-tests.yml list", + "Non-goal: renaming the gaming block (Phase 2); the accounting (steam-backlog-enforcer) and the cache (daily-limits) live elsewhere" + ], + "changes": [ + "leisure_shadow.sh renders daily-limits' cache `leisure_shadow` (contract C5) with gaming_budget.sh's render-cache + single jq pass; always exits 0 so i3blocks never marks it urgent", + "config: [leisure_shadow] block, interval=30, next to [gaming_budget]" + ], + "verification": [ + { + "command": "bash linux_configuration/tests/test_i3blocks_leisure_shadow.sh", + "result": "pass", + "evidence": "All leisure_shadow checks passed (text, 30/29/1/0 boundaries, no data x3, stale, garbage, exit 0 + 3 lines, jq once per mtime, config placement)" + }, + { + "command": "DAILY_LIMITS_CACHE= bash linux_configuration/i3blocks/leisure_shadow.sh", + "result": "pass", + "evidence": "๐Ÿ›‹ SHADOW 0h30 left ยท 9h30 used / ๐Ÿ›‹ 0h30 / #F8F8F2; live cache (no C4 yet): ๐Ÿ›‹ SHADOW โฑ? no data" + }, + { + "command": "shellcheck linux_configuration/i3blocks/leisure_shadow.sh linux_configuration/tests/test_i3blocks_leisure_shadow.sh; bash linux_configuration/tests/test_i3blocks_efficiency.sh", + "result": "pass", + "evidence": "shellcheck clean; All i3blocks efficiency regression tests passed" + } + ], + "risks": [ + "Until steam-backlog-enforcer writes the C4 fields the block reads `โฑ? no data` (by design)", + "One more 30 s block: a steady tick forks nothing (render cache), jq only when the cache changes" + ], + "rollback": [ + "git revert the commit and rm ~/.config/i3blocks/leisure_shadow.sh, then pkill -x i3blocks; i3-msg reload", + "Check the bar shows the gaming block again with no gap" + ] +} diff --git a/linux_configuration/i3blocks/config b/linux_configuration/i3blocks/config index f330570f..da70f465 100644 --- a/linux_configuration/i3blocks/config +++ b/linux_configuration/i3blocks/config @@ -55,6 +55,11 @@ color=#FFFFFF command=~/.config/i3blocks/gaming_budget.sh interval=30 +# Shadow week: the would-be leisure budget. Never urgent (no flashing). +[leisure_shadow] +command=~/.config/i3blocks/leisure_shadow.sh +interval=30 + [screen_locker] command=~/.config/i3blocks/screen_locker.sh interval=30 diff --git a/linux_configuration/i3blocks/leisure_shadow.sh b/linux_configuration/i3blocks/leisure_shadow.sh new file mode 100755 index 00000000..a7e673a6 --- /dev/null +++ b/linux_configuration/i3blocks/leisure_shadow.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# ============================================================================ +# Shadow-week leisure budget for i3blocks: what the leisure budget WOULD say +# (`๐Ÿ›‹ SHADOW 3h12 left ยท 1h48 used`) while only games are enforced. +# +# Renders the `leisure_shadow` part of daily-limits' cache +# ($XDG_RUNTIME_DIR/daily-limits.json, rewritten every 60 s; contract C5) and +# owns no accounting. Same shape as gaming_budget.sh: jq re-parses only when +# the cache's mtime changes, the rendered block is kept in a small file, so a +# steady-state tick forks nothing. Missing numbers say "โฑ? no data", never 0; +# a >180 s old cache says "stale". Yellow under 30 min, red at would-be 0. +# Never urgent: it always exits 0 (i3blocks flashes exit 33 as urgent). +# ============================================================================ + +set -euo pipefail + +readonly LIMITS_CACHE="${DAILY_LIMITS_CACHE:-${XDG_RUNTIME_DIR:-/run/user/$UID}/daily-limits.json}" +readonly RENDER_CACHE="${LEISURE_SHADOW_RENDER_CACHE:-${XDG_RUNTIME_DIR:-/run/user/$UID}/i3blocks-leisure-shadow.render}" +readonly STALE_AFTER_SECONDS=180 + +# Dracula-ish palette, matching gaming_budget.sh. +readonly RED='#FF5555' +readonly YELLOW='#F1FA8C' +readonly ORANGE='#FFB86C' +readonly FOREGROUND='#F8F8F2' + +emit() { + # full_text, short_text, color โ€” the three lines i3blocks reads. + printf '%s\n%s\n%s\n' "$1" "$2" "$3" +} + +# One jq pass renders four lines: generated_at, full text, short text, color. +render_shadow() { + local rendered + rendered=$(jq -r \ + --arg red "$RED" --arg yellow "$YELLOW" --arg orange "$ORANGE" \ + --arg fg "$FOREGROUND" ' + def hm: . as $m + | "\($m / 60 | floor)h\($m % 60 | tostring | if length < 2 then "0" + . else . end)"; + (.leisure_shadow // {}) as $s + | .generated_at, + (if $s.used_minutes == null or $s.budget_minutes == null then + "๐Ÿ›‹ SHADOW โฑ? no data", "๐Ÿ›‹ โฑ?", $orange + elif $s.would_block or ($s.left_minutes // 0) <= 0 then + "๐Ÿ›‹ SHADOW WOULD BLOCK ยท \($s.used_minutes | hm) used", "๐Ÿ›‹ WOULD BLOCK", $red + else + "๐Ÿ›‹ SHADOW \($s.left_minutes | hm) left ยท \($s.used_minutes | hm) used", + "๐Ÿ›‹ \($s.left_minutes | hm)", + (if $s.left_minutes < 30 then $yellow else $fg end) + end)' "$LIMITS_CACHE" 2>/dev/null) || return 1 + printf '%s\n' "$rendered" >"$RENDER_CACHE" +} + +if [[ ! -r $LIMITS_CACHE ]]; then + emit '๐Ÿ›‹ SHADOW โฑ? no data' '๐Ÿ›‹ โฑ?' "$ORANGE" + exit 0 +fi + +if [[ $LIMITS_CACHE -nt $RENDER_CACHE ]]; then + if ! command -v jq >/dev/null 2>&1; then + emit '๐Ÿ›‹ SHADOW no jq' '๐Ÿ›‹ ?' "$ORANGE" + exit 0 + fi + render_shadow || { + emit '๐Ÿ›‹ SHADOW unreadable cache' '๐Ÿ›‹ !' "$RED" + exit 0 + } +fi + +{ + read -r generated_at + read -r full_text + read -r short_text + read -r color +} <"$RENDER_CACHE" || { + emit '๐Ÿ›‹ SHADOW unreadable cache' '๐Ÿ›‹ !' "$RED" + exit 0 +} + +if [[ -n ${NOW_EPOCH:-} ]]; then + now=$NOW_EPOCH +else + printf -v now '%(%s)T' -1 +fi +age=$((now - generated_at)) +if ((age > STALE_AFTER_SECONDS || age < -STALE_AFTER_SECONDS)); then + emit "๐Ÿ›‹ SHADOW โฑ? stale $((age / 60))m" '๐Ÿ›‹ โฑ?' "$ORANGE" + exit 0 +fi + +emit "$full_text" "$short_text" "$color" diff --git a/linux_configuration/tests/test_i3blocks_leisure_shadow.sh b/linux_configuration/tests/test_i3blocks_leisure_shadow.sh new file mode 100755 index 00000000..f04b273f --- /dev/null +++ b/linux_configuration/tests/test_i3blocks_leisure_shadow.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# Behaviour tests for the i3blocks leisure_shadow block: it renders the +# `leisure_shadow` part of daily-limits' cache ($DAILY_LIMITS_CACHE, contract +# C5). Covers text, the yellow/red thresholds, the "no data" states (no cache, +# no key, null numbers), stale/garbage caches, "never urgent" and the +# "re-parse only when the cache changes" contract. + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +BLOCK="$REPO_DIR/i3blocks/leisure_shadow.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 leisure_shadow tests (pacman -S jq)' + +CACHE="$TMP_DIR/daily-limits.json" +RENDER="$TMP_DIR/render" +NOW=1790000000 + +# write_cache USED BUDGET [GENERATED_AT] -- USED/BUDGET may be "null"; +# would_block and left_minutes follow from them as daily-limits writes them. +write_cache() { + local used=$1 budget=$2 generated=${3:-$NOW} left=null block=false + if [[ $used != null && $budget != null ]]; then + left=$((budget > used ? budget - used : 0)) + ((used >= budget)) && block=true + fi + printf '{"date":"2026-10-19","generated_at":%s,"gaming":{"day":"2026-10-19","budget_minutes":300,"used_minutes":0,"ceiling_minutes":480},"leisure_shadow":{"day":"2026-10-19","budget_minutes":%s,"used_minutes":%s,"left_minutes":%s,"would_block":%s,"sources":[]}}\n' \ + "$generated" "$budget" "$used" "$left" "$block" >"$CACHE" + rm -f "$RENDER" # force a re-render for the new content +} + +run_block() { + NOW_EPOCH="$NOW" DAILY_LIMITS_CACHE="$CACHE" LEISURE_SHADOW_RENDER_CACHE="$RENDER" bash "$BLOCK" +} + +line() { + run_block | sed -n "${1}p" +} + +printf 'Checking the normal day: time left and used...\n' +write_cache 108 300 +assert_eq '๐Ÿ›‹ SHADOW 3h12 left ยท 1h48 used' "$(line 1)" 'full text' +assert_eq '๐Ÿ›‹ 3h12' "$(line 2)" 'short text' +assert_eq '#F8F8F2' "$(line 3)" 'plain foreground above 30 min' +assert_eq '3' "$(run_block | wc -l)" 'exactly full/short/colour, no urgent line' + +printf 'Checking yellow strictly under 30 min...\n' +write_cache 270 300 +assert_eq '#F8F8F2' "$(line 3)" '30 min left is not yet yellow' +write_cache 271 300 +assert_eq '#F1FA8C' "$(line 3)" '29 min left is yellow' +assert_eq '๐Ÿ›‹ SHADOW 0h29 left ยท 4h31 used' "$(line 1)" 'yellow text' +write_cache 299 300 +assert_eq '#F1FA8C' "$(line 3)" '1 min left is still yellow, not red' + +printf 'Checking would-block at and over 0, never urgent...\n' +write_cache 300 300 +assert_eq '๐Ÿ›‹ SHADOW WOULD BLOCK ยท 5h00 used' "$(line 1)" 'at 0' +assert_eq '๐Ÿ›‹ WOULD BLOCK' "$(line 2)" 'would-block short text' +assert_eq '#FF5555' "$(line 3)" 'would-block is red' +write_cache 605 600 +assert_eq '๐Ÿ›‹ SHADOW WOULD BLOCK ยท 10h05 used' "$(line 1)" 'over' +set +e +run_block >/dev/null +status=$? +set -e +assert_eq '0' "$status" 'exit 0: i3blocks marks exit 33 urgent (flashing)' +assert_eq '3' "$(run_block | wc -l)" 'would-block emits no urgent line' + +printf 'Checking "no data" is said, never faked as zero...\n' +write_cache null null +assert_eq '๐Ÿ›‹ SHADOW โฑ? no data' "$(line 1)" 'C4 fields absent' +assert_eq '๐Ÿ›‹ โฑ?' "$(line 2)" 'no data short text' +assert_eq '#FFB86C' "$(line 3)" 'no data is orange' +printf '{"date":"2026-10-19","generated_at":%s,"gaming":{}}\n' "$NOW" >"$CACHE" +rm -f "$RENDER" +assert_eq '๐Ÿ›‹ SHADOW โฑ? no data' "$(line 1)" 'a cache without leisure_shadow' +rm -f "$CACHE" +assert_eq '๐Ÿ›‹ SHADOW โฑ? no data' "$(line 1)" 'missing cache' + +printf 'Checking a stale or garbage cache...\n' +write_cache 60 300 "$((NOW - 181))" +assert_eq '๐Ÿ›‹ SHADOW โฑ? stale 3m' "$(line 1)" 'older than 180 s' +assert_eq '#FFB86C' "$(line 3)" 'stale is orange' +write_cache 60 300 "$((NOW - 180))" +assert_eq '๐Ÿ›‹ SHADOW 4h00 left ยท 1h00 used' "$(line 1)" '180 s is still fresh' +printf 'not json' >"$CACHE" +rm -f "$RENDER" +assert_eq '๐Ÿ›‹ SHADOW unreadable cache' "$(line 1)" 'garbage body' +assert_eq '#FF5555' "$(line 3)" 'garbage is red' + +printf 'Checking jq runs only when the cache file changes...\n' +write_cache 60 300 +mkdir -p "$TMP_DIR/bin" +printf '#!/bin/bash\necho x >>"%s/jq.calls"\nexec %s "$@"\n' "$TMP_DIR" "$(command -v jq)" >"$TMP_DIR/bin/jq" +chmod +x "$TMP_DIR/bin/jq" +PATH="$TMP_DIR/bin:$PATH" run_block >/dev/null +PATH="$TMP_DIR/bin:$PATH" run_block >/dev/null +assert_eq '1' "$(wc -l <"$TMP_DIR/jq.calls")" 'two ticks, one unchanged cache: one jq run' +sleep 0.05 +touch "$CACHE" +PATH="$TMP_DIR/bin:$PATH" run_block >/dev/null +assert_eq '2' "$(wc -l <"$TMP_DIR/jq.calls")" 'a rewritten cache is re-parsed' + +printf 'Checking the config wires the block next to gaming_budget...\n' +CONFIG="$REPO_DIR/i3blocks/config" +grep -A3 '^\[leisure_shadow\]$' "$CONFIG" | grep -q '^command=~/.config/i3blocks/leisure_shadow.sh$' || + fail '[leisure_shadow] should run leisure_shadow.sh' +next_section=$(sed -n '/^\[gaming_budget\]$/,$p' "$CONFIG" | grep -m2 '^\[' | tail -1) +assert_eq '[leisure_shadow]' "$next_section" '[leisure_shadow] sits right after [gaming_budget]' +! grep -A4 '^\[leisure_shadow\]$' "$CONFIG" | grep -q '^urgent' || + fail '[leisure_shadow] must never be urgent' + +printf 'All leisure_shadow checks passed.\n'