From 66321e555bf1795f6c2e628aff8ee3688b6c907c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 19 Oct 2026 16:22:02 +0200 Subject: [PATCH] =?UTF-8?q?i3blocks:=20=F0=9F=9B=8B=20Leisure=20label=20an?= =?UTF-8?q?d=20hidden=20SHADOW=20block=20once=20leisure=20is=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N --- .../i3blocks-leisure-live-2026-10-19.json | 12 +++++ .../i3blocks-leisure-live-2026-10-19.json | 37 ++++++++++++++ linux_configuration/i3blocks/gaming_budget.sh | 32 ++++++++---- .../i3blocks/leisure_shadow.sh | 14 ++++- .../tests/test_i3blocks_gaming_budget.sh | 51 +++++++++++++++++++ .../tests/test_i3blocks_leisure_shadow.sh | 23 +++++++++ 6 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/contracts/i3blocks-leisure-live-2026-10-19.json create mode 100644 docs/superpowers/evidence/i3blocks-leisure-live-2026-10-19.json diff --git a/docs/superpowers/contracts/i3blocks-leisure-live-2026-10-19.json b/docs/superpowers/contracts/i3blocks-leisure-live-2026-10-19.json new file mode 100644 index 00000000..16c28cee --- /dev/null +++ b/docs/superpowers/contracts/i3blocks-leisure-live-2026-10-19.json @@ -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" +} diff --git a/docs/superpowers/evidence/i3blocks-leisure-live-2026-10-19.json b/docs/superpowers/evidence/i3blocks-leisure-live-2026-10-19.json new file mode 100644 index 00000000..834cdc7b --- /dev/null +++ b/docs/superpowers/evidence/i3blocks-leisure-live-2026-10-19.json @@ -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" + ] +} diff --git a/linux_configuration/i3blocks/gaming_budget.sh b/linux_configuration/i3blocks/gaming_budget.sh index a260059a..67daf377 100755 --- a/linux_configuration/i3blocks/gaming_budget.sh +++ b/linux_configuration/i3blocks/gaming_budget.sh @@ -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 diff --git a/linux_configuration/i3blocks/leisure_shadow.sh b/linux_configuration/i3blocks/leisure_shadow.sh index 124634c0..0357c3f7 100755 --- a/linux_configuration/i3blocks/leisure_shadow.sh +++ b/linux_configuration/i3blocks/leisure_shadow.sh @@ -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 diff --git a/linux_configuration/tests/test_i3blocks_gaming_budget.sh b/linux_configuration/tests/test_i3blocks_gaming_budget.sh index 3eb8227a..9dc98ff9 100755 --- a/linux_configuration/tests/test_i3blocks_gaming_budget.sh +++ b/linux_configuration/tests/test_i3blocks_gaming_budget.sh @@ -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" diff --git a/linux_configuration/tests/test_i3blocks_leisure_shadow.sh b/linux_configuration/tests/test_i3blocks_leisure_shadow.sh index 590fc392..40f5b964 100755 --- a/linux_configuration/tests/test_i3blocks_leisure_shadow.sh +++ b/linux_configuration/tests/test_i3blocks_leisure_shadow.sh @@ -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$' ||