i3blocks: leisure_shadow says "no data" when generated_at is missing
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

A daily-limits cache without a numeric generated_at made the age
arithmetic hit "null: unbound variable" under set -u: exit 1 and an empty
block. The render now floors a numeric generated_at and the script emits
the orange "SHADOW no data" fallback for anything else, still exiting 0.

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 13:17:59 +02:00
parent e190d9a4bd
commit 5e6aee64cf
3 changed files with 58 additions and 1 deletions

View File

@ -0,0 +1,36 @@
{
"intent": "leisure_shadow.sh always exits 0 and prints the honest '🛋 SHADOW ⏱? no data' fallback when the daily-limits cache has no usable generated_at, instead of dying with 'null: unbound variable' and an empty i3blocks block.",
"scope": [
"linux_configuration/i3blocks/leisure_shadow.sh",
"linux_configuration/tests/test_i3blocks_leisure_shadow.sh",
"Non-goal: no change to the cache producer (daily-limits) or the stale threshold"
],
"changes": [
"jq renders generated_at floored when numeric, 'none' otherwise; the script emits the orange no-data fallback for any non-integer value before the age arithmetic",
"Test covers generated_at absent, null and a string (exit 0, no-data text, orange) and a fractional timestamp still rendering normally"
],
"verification": [
{
"command": "bash linux_configuration/tests/test_i3blocks_leisure_shadow.sh",
"result": "pass",
"evidence": "RED before the fix: 'FAIL: generated_at 'absent' still exits 0 (expected '0', actual '1')'; after: 'All leisure_shadow checks passed.'"
},
{
"command": "shellcheck linux_configuration/i3blocks/leisure_shadow.sh linux_configuration/tests/test_i3blocks_leisure_shadow.sh",
"result": "pass",
"evidence": "no findings"
},
{
"command": "DAILY_LIMITS_CACHE=<cache without generated_at> bash linux_configuration/i3blocks/leisure_shadow.sh",
"result": "pass",
"evidence": "printed '🛋 SHADOW ⏱? no data' / '🛋 ⏱?' / '#FFB86C', rc=0; against the live cache: '🛋 SHADOW 6h34 left · 0h26 used', rc=0"
}
],
"risks": [
"A producer that writes generated_at as a numeric string would now show 'no data' rather than a time; daily-limits writes a JSON number"
],
"rollback": [
"git revert the commit",
"Re-run test_i3blocks_leisure_shadow.sh and check the bar block renders"
]
}

View File

@ -39,7 +39,7 @@ render_shadow() {
def hm: . as $m
| "\($m / 60 | floor)h\($m % 60 | tostring | if length < 2 then "0" + . else . end)";
(.leisure_shadow // {}) as $s
| .generated_at,
| (.generated_at | if type == "number" then floor else "none" end),
(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
@ -83,6 +83,11 @@ if [[ -n ${NOW_EPOCH:-} ]]; then
else
printf -v now '%(%s)T' -1
fi
# A cache without a numeric generated_at cannot be dated: say so, never crash.
if [[ ! $generated_at =~ ^-?[0-9]+$ ]]; then
emit '🛋 SHADOW ⏱? no data' '🛋 ⏱?' "$ORANGE"
exit 0
fi
age=$((now - generated_at))
if ((age > STALE_AFTER_SECONDS || age < -STALE_AFTER_SECONDS)); then
emit "🛋 SHADOW ⏱? stale $((age / 60))m" '🛋 ⏱?' "$ORANGE"

View File

@ -100,6 +100,22 @@ 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 cache without a usable generated_at says no data, exit 0...\n'
for generated in '' '"generated_at":null,' '"generated_at":"soon",'; do
printf '{%s"leisure_shadow":{"budget_minutes":300,"used_minutes":60,"left_minutes":240,"would_block":false}}\n' \
"$generated" >"$CACHE"
rm -f "$RENDER"
set +e
output=$(run_block 2>&1)
status=$?
set -e
assert_eq '0' "$status" "generated_at '${generated:-absent}' still exits 0"
assert_eq '🛋 SHADOW ⏱? no data' "$(sed -n 1p <<<"$output")" "generated_at '${generated:-absent}'"
assert_eq '#FFB86C' "$(sed -n 3p <<<"$output")" "generated_at '${generated:-absent}' is orange"
done
write_cache 60 300 "$NOW.75"
assert_eq '🛋 SHADOW 4h00 left · 1h00 used' "$(line 1)" 'a fractional generated_at is a timestamp'
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'