i3blocks: 🛋 Leisure label and hidden SHADOW block once leisure is live

Phase 2 of the leisure rollout, dormant until daily-limits' cache says
"leisure_active": true. gaming_budget.sh then labels the block
"🛋 Leisure" (short "🛋", same colour logic, stale state keeps the label);
leisure_shadow.sh prints nothing so i3bar hides it. A missing, false or
non-boolean key keeps today's output.

Also: gaming_budget.sh floors generated_at and says "no data" for a
non-numeric one; a fractional timestamp used to make it exit 1.

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 16:22:02 +02:00
parent 5e6aee64cf
commit 66321e555b
6 changed files with 159 additions and 10 deletions

View File

@ -0,0 +1,12 @@
{
"title": "i3blocks: leisure labels and hidden SHADOW block once the leisure budget is live",
"objective": "Build the i3blocks half of leisure Phase 2 dormant: when daily-limits' cache has leisure_active true, gaming_budget.sh shows '🛋 Leisure' with the same colour logic and leisure_shadow.sh prints nothing; a missing key keeps current behaviour; both always exit 0 and stay shellcheck clean.",
"acceptance_criteria": [
"leisure_active true: gaming_budget full text starts '🛋 Leisure', short text '🛋', colours unchanged (green/yellow/orange/red/grey)",
"leisure_active true: leisure_shadow prints 0 lines and exits 0, even for stale/no-data caches",
"leisure_active missing/false/null/non-boolean: output identical to before",
"gaming_budget exits 0 on a fractional or non-numeric generated_at"
],
"out_of_scope": ["daily-limits itself (own repo)", "enforcer and extension behaviour"],
"verifier": "linux_configuration/tests/test_i3blocks_gaming_budget.sh, test_i3blocks_leisure_shadow.sh, test_i3blocks_efficiency.sh, shellcheck, pre-commit"
}

View File

@ -0,0 +1,37 @@
{
"intent": "Phase 2 of the leisure rollout, dormant until go-live: once daily-limits' cache says leisure_active, the gaming block reads '🛋 Leisure ...' (same colours) and the SHADOW block hides itself.",
"scope": [
"linux_configuration/i3blocks/gaming_budget.sh, leisure_shadow.sh and their two test scripts",
"Non-goal: no accounting, no new keys written; a missing/false key keeps today's output"
],
"changes": [
"gaming_budget.sh: jq picks the label (🎮 or '🛋 Leisure') from .leisure_active == true; a 5th render line carries it to the stale state; an old 4-line render still reads as 🎮",
"gaming_budget.sh: generated_at is floored and a non-numeric one says 'no data' (it used to exit 1 on a fractional timestamp)",
"leisure_shadow.sh: when live the render is empty text + 'hidden' colour sentinel and the block prints nothing, exit 0, whatever the cache age"
],
"verification": [
{
"command": "bash linux_configuration/tests/test_i3blocks_gaming_budget.sh && bash linux_configuration/tests/test_i3blocks_leisure_shadow.sh && bash linux_configuration/tests/test_i3blocks_efficiency.sh",
"result": "pass",
"evidence": "All gaming_budget checks passed. All leisure_shadow checks passed. All i3blocks efficiency regression tests passed. (both new suites RED before the change)"
},
{
"command": "shellcheck linux_configuration/i3blocks/*.sh linux_configuration/tests/test_i3blocks_{gaming_budget,leisure_shadow}.sh",
"result": "pass",
"evidence": "no findings"
},
{
"command": "fixture caches (daily-limits sample report, leisure_active false/true) through both blocks",
"result": "pass",
"evidence": "off: '🎮 1h26 left · 1h34/3h00 ...' + '🛋 SHADOW 3h12 left · 1h48 used'; on: '🛋 Leisure 1h26 left · 1h34/3h00 ...' + (nothing), all exit 0"
}
],
"risks": [
"One tick after deploy the old 4-line render is read: shown as 🎮, never an error",
"Hidden shadow block depends on daily-limits >= ed274f2 writing leisure_active"
],
"rollback": [
"git revert the commit; the symlinks in ~/.config/i3blocks pick it up on the next tick",
"Check both blocks render from the live cache again"
]
}

View File

@ -10,6 +10,10 @@
# when its mtime changes; the rendered block is kept in a small file, so a
# steady-state tick forks nothing. A missing or >180 s old cache shows "⏱?",
# never old numbers. Left-click opens `daily-limits --gui`.
#
# Phase 2 (leisure rollout): once the cache's top-level `leisure_active` is
# true, the same block is the leisure budget: 🎮 becomes `🛋 Leisure`, colours
# unchanged. A missing/false/non-boolean key keeps the 🎮 gaming label.
# ============================================================================
set -euo pipefail
@ -41,7 +45,8 @@ if [[ ${BLOCK_BUTTON:-0} -eq 1 ]]; then
fi
fi
# One jq pass renders four lines: generated_at, full text, short text, color.
# One jq pass renders five lines: generated_at, full text, short text, color
# and the label (🎮, or `🛋 Leisure` once live) the stale state reuses.
# Thresholds mirror the enforcer's own warn_at (1h, 30m, 10m). Bonuses still
# not done are listed as " +💪2h +🧩?1h" ("?" = status unknown, still counted);
# a gaming day other than the date is shown as " (10-08)"; used_minutes == null means the enforcer
@ -54,28 +59,30 @@ render_limits() {
def hm: . as $m
| "\($m / 60 | floor)h\($m % 60 | tostring | if length < 2 then "0" + . else . end)";
def bonus_hm: if . < 60 then "\(.)m" else hm | sub("h00$"; "h") end;
{workout: "💪", leetcode: "🧩", reading: "📖", anki: "🗂", automation: "⚙", tutor: "⚙"} as $icon
{workout: "💪", leetcode: "🧩", reading: "📖", anki: "🗂", automation: "⚙", tutor: "⚙"} as $earner_icon
| .gaming as $g
| (if .leisure_active == true then ["🛋 Leisure", "🛋"] else ["🎮", "🎮"] end) as [$label, $icon]
| ([.earners[] | select(.status != "done" and .gaming_minutes > 0)
| "+\($icon[.name] // .label)\(if .status == "unknown" then "?" else "" end)\(.gaming_minutes | bonus_hm)"]
| "+\($earner_icon[.name] // .label)\(if .status == "unknown" then "?" else "" end)\(.gaming_minutes | bonus_hm)"]
| if length > 0 then " " + join(" ") else "" end) as $todo
| (if (.gaming.day // .date) != .date then " (\((.gaming.day // .date)[5:]))" else "" end) as $day
| .generated_at,
| (.generated_at | if type == "number" then floor else "none" end),
(if $g.used_minutes == null then
"🎮 budget \($g.budget_minutes | hm)\($day)\($todo)", "🎮 \($g.budget_minutes | hm)", $grey
"\($label) budget \($g.budget_minutes | hm)\($day)\($todo)", "\($icon) \($g.budget_minutes | hm)", $grey
else
([$g.budget_minutes - $g.used_minutes, 0] | max) as $left
| "\($g.used_minutes | hm)/\($g.budget_minutes | hm)\($day)" as $ratio
| if $left <= 0 then
"🎮 BLOCKED · \($ratio)\($todo)", "🎮 BLOCKED", $red
"\($label) BLOCKED · \($ratio)\($todo)", "\($icon) BLOCKED", $red
else
"🎮 \($left | hm) left · \($ratio)\($todo)", "🎮 \($left | hm)",
"\($label) \($left | hm) left · \($ratio)\($todo)", "\($icon) \($left | hm)",
(if $left <= 10 then $red
elif $left <= 30 then $orange
elif $left <= 60 then $yellow
else $green end)
end
end)' "$LIMITS_CACHE" 2>/dev/null) || return 1
end),
$label' "$LIMITS_CACHE" 2>/dev/null) || return 1
printf '%s\n' "$rendered" >"$RENDER_CACHE"
}
@ -100,6 +107,8 @@ fi
read -r full_text
read -r short_text
read -r color
# A render from before the label line (one tick after an upgrade) is 🎮.
read -r label || label='🎮'
} <"$RENDER_CACHE" || {
emit '🎮 unreadable cache' '🎮 !' "$RED"
exit 0
@ -110,9 +119,14 @@ 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 "$label ⏱? no data" "${label%% *} ⏱?" "$ORANGE"
exit 0
fi
age=$((now - generated_at))
if ((age > STALE_AFTER_SECONDS || age < -STALE_AFTER_SECONDS)); then
emit "🎮 ⏱? stale $((age / 60))m" '🎮 ⏱?' "$ORANGE"
emit "$label ⏱? stale $((age / 60))m" "${label%% *} ⏱?" "$ORANGE"
exit 0
fi

View File

@ -11,6 +11,11 @@
# 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).
#
# Phase 2 (leisure rollout): once the cache's top-level `leisure_active` is
# true the shadow week is over and the block prints nothing (i3bar hides it);
# gaming_budget.sh shows the real leisure budget instead. A missing/false key
# keeps the shadow block.
# ============================================================================
set -euo pipefail
@ -31,6 +36,8 @@ emit() {
}
# One jq pass renders four lines: generated_at, full text, short text, color.
# Once live full/short are empty and color is "hidden" (non-empty, so $(...)
# cannot strip the lines): the "hidden" render.
render_shadow() {
local rendered
rendered=$(jq -r \
@ -40,7 +47,9 @@ render_shadow() {
| "\($m / 60 | floor)h\($m % 60 | tostring | if length < 2 then "0" + . else . end)";
(.leisure_shadow // {}) as $s
| (.generated_at | if type == "number" then floor else "none" end),
(if $s.used_minutes == null or $s.budget_minutes == null then
(if .leisure_active == true then
"", "", "hidden"
elif $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
@ -78,6 +87,9 @@ fi
exit 0
}
# Hidden (leisure budget live): print nothing, whatever the cache's age.
[[ -n $full_text ]] || exit 0
if [[ -n ${NOW_EPOCH:-} ]]; then
now=$NOW_EPOCH
else

View File

@ -104,6 +104,44 @@ assert_eq '🎮 2h00 left · 1h00/3h00 (10-08) +🧩1h' "$(line 1)" 'previous ga
write_cache 60 180 "$NOW" "$D" leetcode:todo:60
assert_eq '🎮 2h00 left · 1h00/3h00 +🧩1h' "$(line 1)" 'same day: no note'
# set_live VALUE -- rewrite the cache's top-level leisure_active (Phase 2).
set_live() {
jq -c ".leisure_active = $1" "$CACHE" >"$CACHE.new" && mv "$CACHE.new" "$CACHE"
rm -f "$RENDER"
}
printf 'Checking the leisure budget, once live, is labelled 🛋 Leisure...\n'
write_cache 360 480 "$NOW" "$D" workout:done:120 reading:todo:60
set_live true
assert_eq '🛋 Leisure 2h00 left · 6h00/8h00 +📖1h' "$(line 1)" 'live full text'
assert_eq '🛋 2h00' "$(line 2)" 'live short text'
assert_eq '#50FA7B' "$(line 3)" 'live keeps the colour logic (green)'
assert_eq '3' "$(run_block | wc -l)" 'live emits exactly full/short/colour'
write_cache 460 480 "$NOW" "$D"
set_live true
assert_eq '#FFB86C' "$(line 3)" 'live keeps the colour logic (orange)'
write_cache 300 300 "$NOW" "$D" leetcode:todo:60
set_live true
assert_eq '🛋 Leisure BLOCKED · 5h00/5h00 +🧩1h' "$(line 1)" 'live blocked text'
assert_eq '🛋 BLOCKED' "$(line 2)" 'live blocked short text'
assert_eq '#FF5555' "$(line 3)" 'live blocked is red'
write_cache null 180 "$NOW" "$D"
set_live true
assert_eq '🛋 Leisure budget 3h00' "$(line 1)" 'live budget only'
assert_eq '🛋 3h00' "$(line 2)" 'live budget only short text'
write_cache 60 180 "$((NOW - 181))" "$D"
set_live true
assert_eq '🛋 Leisure ⏱? stale 3m' "$(line 1)" 'live stale keeps the leisure label'
assert_eq '🛋 ⏱?' "$(line 2)" 'live stale short text'
for value in false null '"yes"'; do
write_cache 60 180 "$NOW" "$D"
set_live "$value"
assert_eq '🎮 2h00 left · 1h00/3h00' "$(line 1)" "leisure_active $value is not live"
done
printf '%s\n%s\n%s\n%s\n' "$NOW" '🎮 old render' '🎮 old' '#50FA7B' >"$RENDER"
touch -d '+1 min' "$RENDER"
assert_eq '🎮 old render' "$(line 1)" 'a 4-line render from before the label line still shows'
printf 'Checking a stale or missing cache never shows numbers as fresh...\n'
write_cache 60 180 "$((NOW - 181))" "$D"
assert_eq '🎮 ⏱? stale 3m' "$(line 1)" 'older than 180 s'
@ -114,6 +152,19 @@ rm -f "$CACHE"
assert_eq '🎮 ⏱? no data' "$(line 1)" 'missing cache'
assert_eq '🎮 ⏱?' "$(line 2)" 'missing cache short text'
printf 'Checking an odd generated_at never crashes the block (always exit 0)...\n'
write_cache 60 180 "$NOW.75" "$D"
assert_eq '🎮 2h00 left · 1h00/3h00' "$(line 1)" 'a fractional generated_at is a timestamp'
for generated in null '"soon"'; do
write_cache 60 180 "$generated" "$D"
set +e
output=$(run_block 2>&1)
status=$?
set -e
assert_eq '0' "$status" "generated_at $generated still exits 0"
assert_eq '🎮 ⏱? no data' "$(sed -n 1p <<<"$output")" "generated_at $generated says no data"
done
printf 'Checking an unreadable cache is red...\n'
printf 'not json' >"$CACHE"
rm -f "$RENDER"

View File

@ -140,6 +140,29 @@ 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 block hides itself once the leisure budget is live...\n'
# set_live VALUE -- rewrite the cache's top-level leisure_active (Phase 2).
set_live() {
jq -c ".leisure_active = $1" "$CACHE" >"$CACHE.new" && mv "$CACHE.new" "$CACHE"
rm -f "$RENDER"
}
for spec in '108 300' '300 300' 'null null' "60 300 $((NOW - 600))"; do
read -r used budget generated <<<"$spec"
write_cache "$used" "$budget" "${generated:-$NOW}"
set_live true
set +e
output=$(run_block)
status=$?
set -e
assert_eq '0' "$status" "live ($spec) exits 0"
assert_eq '' "$output" "live ($spec) prints nothing: the block is hidden"
done
for value in false null; do
write_cache 108 300
set_live "$value"
assert_eq '🛋 SHADOW 3h12 left · 1h48 used' "$(line 1)" "leisure_active $value still shows the shadow"
done
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$' ||