i3blocks: shadow-week leisure block (leisure_shadow.sh)
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 (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
This commit is contained in:
Krzysztof Rudnicki 2026-10-19 12:27:15 +02:00
parent 09fa296445
commit e190d9a4bd
6 changed files with 286 additions and 0 deletions

View File

@ -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

View File

@ -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> left · <used> used`, yellow under 30 min, red `🛋 SHADOW WOULD BLOCK · <used> 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"
}

View File

@ -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=<forced fixture 570/600> 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"
]
}

View File

@ -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

View File

@ -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"

View File

@ -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'