diff --git a/docs/superpowers/contracts/i3blocks-daily-limits-cache-2026-10-09.json b/docs/superpowers/contracts/i3blocks-daily-limits-cache-2026-10-09.json new file mode 100644 index 00000000..095af797 --- /dev/null +++ b/docs/superpowers/contracts/i3blocks-daily-limits-cache-2026-10-09.json @@ -0,0 +1,15 @@ +{ + "title": "i3blocks: shutdown and gaming blocks render from the daily-limits cache", + "objective": "Both status-bar blocks show today's shutdown time, gaming budget and the unearned earners from $XDG_RUNTIME_DIR/daily-limits.json, with no earner logic duplicated in shell and no fork in the steady-state tick beyond bash itself.", + "acceptance_criteria": [ + "Live output keeps the existing format (โป HH:MM +๐Ÿ’ช2h ... โ†’max 23:00, ๐ŸŽฎ Xh left ยท used/budget +...)", + "A cache older than 180 s or missing shows โฑ? and never stale numbers", + "Unknown earners are shown with a ? marker, not hidden", + "test_i3blocks_gaming_budget.sh, test_i3blocks_shutdown_hint.sh and test_i3blocks_efficiency.sh pass, and pre-commit is clean" + ], + "out_of_scope": [ + "The daily-limits app itself (kuhyx/daily-limits)", + "Shutdown enforcement in screen-locker / digital-wellbeing" + ], + "verifier": "bash linux_configuration/i3blocks/{gaming_budget,shutdown_countdown}.sh; bash linux_configuration/tests/test_i3blocks_*.sh; pre-commit run" +} diff --git a/docs/superpowers/evidence/fix-music-parallelism-drift-guard-2026-08-23.json b/docs/superpowers/evidence/fix-music-parallelism-drift-guard-2026-08-23.json deleted file mode 100644 index 712b842c..00000000 --- a/docs/superpowers/evidence/fix-music-parallelism-drift-guard-2026-08-23.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "intent": "Unblock every push: the pre-push ci-mirror was failing on test_music_parallelism.sh, whose worktree drift guard still assumed the pre-flattening layout.", - "scope": [ - "linux_configuration/tests/test_music_parallelism.sh - derive the lib prefix instead of hardcoding it", - "Non-goal: music_parallelism.sh itself, which is correct - it sources ../../lib/common.sh, matching where lib/ actually sits after the flattening." - ], - "changes": [ - "The guard hardcoded ../lib/common.sh while the script sources ../../lib/common.sh. The flattening that moved the script from scripts/periodic_background/... up to periodic_background/... changed its depth, so the test failed on a layout that is in fact correct.", - "The prefix is now read out of the script under test with sed, so a future reorganisation cannot desynchronise the two again. The failure message reports the real prefix rather than a fixed string.", - "Matching the literal text $SCRIPT_DIR meant matching a dollar sign as data, which shellcheck flags as SC2016. Spelled the byte via printf '\\\\x24' rather than adding a suppression." - ], - "verification": [ - { - "command": "bash linux_configuration/tests/test_music_parallelism.sh", - "result": "pass", - "evidence": "'music_parallelism.sh regression checks passed.' Previously failed with 'worktree layout drift: ... resolves ../lib/common.sh ...'." - }, - { - "command": "Mutation check in a detached worktree: rewrote the script to source ../../../lib/common.sh", - "result": "pass", - "evidence": "The guard failed as intended and named the real prefix (../../../). Confirms the guard is non-vacuous rather than merely passing." - }, - { - "command": "pre-commit run --files linux_configuration/tests/test_music_parallelism.sh", - "result": "pass", - "evidence": "shellcheck and shfmt clean; SC2016 resolved without a suppression." - }, - { - "command": "systemd-run --scope -p MemoryMax=2G -p MemorySwapMax=0 /usr/bin/time -v python -m pytest ...", - "result": "pass", - "evidence": "1798 passed in 20.88s, Maximum RSS 293872 KB (~0.29 GB) under the existing 2G cap. The earlier 'Killed' was transient host pressure, not an inadequate ceiling; no cap change needed." - } - ], - "risks": [ - "The sed pattern matches the specific shape of the source line. If that line is rewritten to a different form the test fails loudly with 'cannot determine how ... sources lib/common.sh' rather than silently passing, which is the intended direction." - ], - "rollback": [ - "git revert the commit; the test returns to its hardcoded prefix and the pre-push gate fails again until the layout matches.", - "After rollback, run bash linux_configuration/tests/test_music_parallelism.sh to confirm the expected failure." - ] -} diff --git a/docs/superpowers/evidence/i3blocks-daily-limits-cache-2026-10-09.json b/docs/superpowers/evidence/i3blocks-daily-limits-cache-2026-10-09.json new file mode 100644 index 00000000..7b5a5510 --- /dev/null +++ b/docs/superpowers/evidence/i3blocks-daily-limits-cache-2026-10-09.json @@ -0,0 +1,39 @@ +{ + "intent": "The i3blocks shutdown and gaming_budget blocks render from the daily-limits cache ($XDG_RUNTIME_DIR/daily-limits.json), so the earner list, sizes and ceiling come from the earned_time registry instead of being duplicated in shell; left-click opens daily-limits --gui.", + "scope": [ + "linux_configuration/i3blocks/gaming_budget.sh, shutdown_countdown.sh", + "linux_configuration/tests/test_i3blocks_{gaming_budget,shutdown_hint,efficiency}.sh", + "Non-goal: changing the shutdown enforcement itself (screen-locker / digital-wellbeing)" + ], + "changes": [ + "gaming_budget.sh: drop the curl to the enforcer /api/budget, the config.json read and the fallback bonus sizes; read gaming budget/used/day and the not-done earners from the cache", + "shutdown_countdown.sh: replace earner_hint() with render_limits/load_limits fed by todo[]; โ†’max from shutdown_after reaching the ceiling; unknown earners shown with '?'", + "Both: a cache older than 180 s (or from the future) shows a โฑ? marker and never stale numbers; jq runs only when the cache mtime changes (rendered line cached in $XDG_RUNTIME_DIR)", + "Tests rewritten from the curl stub to DAILY_LIMITS_CACHE fixtures; the shutdown exec bound drops from 2 to 1" + ], + "verification": [ + { + "command": "bash linux_configuration/i3blocks/gaming_budget.sh; bash linux_configuration/i3blocks/shutdown_countdown.sh (live, daily-limits timer running)", + "result": "pass", + "evidence": "๐ŸŽฎ 0h43 left ยท 2h17/3h00 +๐Ÿ’ช2h +๐Ÿงฉ1h +๐Ÿ“–1h +๐Ÿ—‚30m +โš™30m / โป 20:00 +๐Ÿ’ช2h +๐Ÿงฉ1h +๐Ÿ“–1h +๐Ÿ—‚30m +โš™30m โ†’max 23:00 โ€” same format as before the change" + }, + { + "command": "capped.sh bash tests/test_i3blocks_gaming_budget.sh; test_i3blocks_shutdown_hint.sh; test_i3blocks_efficiency.sh", + "result": "pass", + "evidence": "All gaming_budget checks passed. / All shutdown hint tests passed. / All i3blocks efficiency regression tests passed." + }, + { + "command": "strace -f -c (clone+execve per tick)", + "result": "pass", + "evidence": "gaming steady state 0 clone + 1 execve (was 4 + 3 with curl and jq); shutdown 3 clone + 1 execve (was 8 + 3)" + } + ], + "risks": [ + "If the daily-limits user timer stops, both blocks show โฑ? (shutdown falls back to the local config time, which is still correct)", + "daily-limits must be on PATH for left-click; otherwise the click does nothing" + ], + "rollback": [ + "git revert this commit; the blocks go back to the enforcer API and the in-script earner table", + "After a rollback, check the bar shows numbers again with the enforcer running" + ] +} diff --git a/linux_configuration/i3blocks/gaming_budget.sh b/linux_configuration/i3blocks/gaming_budget.sh index 06693535..6580194e 100755 --- a/linux_configuration/i3blocks/gaming_budget.sh +++ b/linux_configuration/i3blocks/gaming_budget.sh @@ -2,109 +2,111 @@ # ============================================================================ # Gaming time budget for i3blocks: time left today, used/total, and which -# bonuses (workout, LeetCode, reading, Anki, Automation, ...) are still there to be earned. +# bonuses (workout, LeetCode, reading, Anki, Automation, ...) are still there +# to be earned. # -# Reads steam-backlog-enforcer's /api/budget, which already folds in the -# screen-locker, leetcode-guard and book-guard bonuses. Like screen_locker.sh -# it is never blank: a dead server shows the last value marked stale, or red. -# Click opens the enforcer's web UI. +# Renders daily-limits' cache ($XDG_RUNTIME_DIR/daily-limits.json, rewritten +# every 60 s) and owns no earner logic. The cache is re-parsed with jq only +# 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`. # ============================================================================ set -euo pipefail -readonly API="${GAMING_BUDGET_API:-http://127.0.0.1:8000/api/budget}" -readonly UI='http://127.0.0.1:8000/' -# Only for the *size* of bonuses not yet earned; /api/budget reports earned -# ones as 0. The file holds secrets, so it goes to jq by path, never on argv. -readonly CONFIG="${GAMING_BUDGET_CONFIG:-$HOME/.config/steam_backlog_enforcer/config.json}" -readonly CACHE="${GAMING_BUDGET_CACHE:-${XDG_RUNTIME_DIR:-/tmp}/i3blocks-gaming-budget.cache}" +readonly LIMITS_CACHE="${DAILY_LIMITS_CACHE:-${XDG_RUNTIME_DIR:-/tmp}/daily-limits.json}" +readonly RENDER_CACHE="${DAILY_LIMITS_RENDER_CACHE:-${XDG_RUNTIME_DIR:-/tmp}/i3blocks-gaming-budget.render}" +readonly STALE_AFTER_SECONDS=180 # Dracula-ish palette, matching screen_locker.sh. readonly RED='#FF5555' readonly YELLOW='#F1FA8C' readonly GREEN='#50FA7B' readonly ORANGE='#FFB86C' +readonly GREY='#6272A4' emit() { # full_text, short_text, color โ€” the three lines i3blocks reads. printf '%s\n%s\n%s\n' "$1" "$2" "$3" } -if [[ ${BLOCK_BUTTON:-0} -ne 0 ]]; then - xdg-open "$UI" >/dev/null 2>&1 & +if [[ ${BLOCK_BUTTON:-0} -eq 1 ]] && command -v daily-limits >/dev/null 2>&1; then + daily-limits --gui >/dev/null 2>&1 & fi -if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then - emit '๐ŸŽฎ no curl/jq' '๐ŸŽฎ ?' "$ORANGE" - exit 0 -fi - -budget_json=$(curl -fsS --max-time 2 "$API" 2>/dev/null || true) -if [[ -z $budget_json ]]; then - if [[ -r $CACHE ]]; then - emit "$(<"$CACHE") (stale)" '๐ŸŽฎ ?' "$ORANGE" - else - emit '๐ŸŽฎ budget server down' '๐ŸŽฎ ?' "$RED" - fi - exit 0 -fi - -config_path=/dev/null -[[ -r $CONFIG ]] && config_path=$CONFIG - -# One jq pass renders all three lines, so a tick costs curl + jq and no more. -# The first line is "cache" or "nocache": error states must not overwrite the -# last good value that the stale path shows. -# Thresholds mirror the enforcer's own warn_at (1h, 30m, 10m). -rendered=$(jq -r \ - --slurpfile cfg "$config_path" \ - --arg red "$RED" --arg orange "$ORANGE" --arg yellow "$YELLOW" --arg green "$GREEN" ' - def hm: (. / 60 | round) as $m - | "\($m / 60 | floor)h\($m % 60 | tostring | if length < 2 then "0" + . else . end)"; - def bonus_hm: if . < 3600 then "\(. / 60 | round)m" else hm | sub("h00$"; "h") end; - ($cfg[0] // {}) as $c - | if .state_status != "ok" then ["nocache", "๐ŸŽฎ state \(.state_status)", "๐ŸŽฎ !", $red] - elif .ok != true then ["nocache", "๐ŸŽฎ budget error", "๐ŸŽฎ !", $red] - else - .today as $t - | .rules.bonuses as $b - | ([$t.seconds_remaining, 0] | max) as $left - | "\($t.seconds_used | hm)/\($t.budget_seconds | hm)" as $ratio - # Unearned bonuses: [earned, size, icon]. A daemon that lists the - # registry (.rules.earners) is the source of truth, so a new earner - # needs only an icon here; an older daemon falls back to the three - # fixed fields and the configured sizes. - | {workout: "๐Ÿ’ช", leetcode: "๐Ÿงฉ", reading: "๐Ÿ“–", anki: "๐Ÿ—‚", automation: "โš™"} as $icon - | (if .rules.earners then - [.rules.earners[] | [.earned_seconds, .bonus_seconds, ($icon[.name] // .label)]] +# One jq pass renders four lines: generated_at, full text, short text, color. +# 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 +# could not report usage, so only the budget is shown. +render_limits() { + local rendered + rendered=$(jq -r \ + --arg red "$RED" --arg orange "$ORANGE" --arg yellow "$YELLOW" \ + --arg green "$GREEN" --arg grey "$GREY" ' + 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: "โš™"} as $icon + | .gaming as $g + | ([.earners[] | select(.status != "done" and .gaming_minutes > 0) + | "+\($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, + (if $g.used_minutes == null then + "๐ŸŽฎ budget \($g.budget_minutes | hm)\($day)\($todo)", "๐ŸŽฎ \($g.budget_minutes | hm)", $grey else - [ [$b.workout, ($c.workout_bonus_seconds // 7200), "๐Ÿ’ช"], - [$b.leetcode, ($c.leetcode_bonus_seconds // 3600), "๐Ÿงฉ"], - [$b.reading, ($c.reading_bonus_seconds // 3600), "๐Ÿ“–"] ] - end) - | map(select((.[0] // 0) == 0 and .[1] > 0) | "+\(.[2])\(.[1] | bonus_hm)") - | (if length > 0 then " " + join(" ") else "" end) as $todo - | if $t.blocked or $left <= 0 then - ["cache", "๐ŸŽฎ BLOCKED ยท \($ratio)\($todo)", "๐ŸŽฎ BLOCKED", $red] - else - ["cache", "๐ŸŽฎ \($left | hm) left ยท \($ratio)\($todo)", "๐ŸŽฎ \($left | hm)", - (if $left <= 600 then $red - elif $left <= 1800 then $orange - elif $left <= 3600 then $yellow - else $green end)] - end - end - | .[]' <<<"$budget_json" 2>/dev/null) || { - emit '๐ŸŽฎ unreadable budget' '๐ŸŽฎ !' "$RED" - exit 0 + ([$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 + else + "๐ŸŽฎ \($left | hm) left ยท \($ratio)\($todo)", "๐ŸŽฎ \($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 + printf '%s\n' "$rendered" >"$RENDER_CACHE" } +if [[ ! -r $LIMITS_CACHE ]]; then + emit '๐ŸŽฎ โฑ? no data' '๐ŸŽฎ โฑ?' "$ORANGE" + exit 0 +fi + +if [[ $LIMITS_CACHE -nt $RENDER_CACHE ]]; then + if ! command -v jq >/dev/null 2>&1; then + emit '๐ŸŽฎ no jq' '๐ŸŽฎ ?' "$ORANGE" + exit 0 + fi + render_limits || { + emit '๐ŸŽฎ unreadable cache' '๐ŸŽฎ !' "$RED" + exit 0 + } +fi + { - read -r cache_flag + read -r generated_at read -r full_text read -r short_text read -r color -} <<<"$rendered" +} <"$RENDER_CACHE" || { + emit '๐ŸŽฎ 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 "๐ŸŽฎ โฑ? stale $((age / 60))m" '๐ŸŽฎ โฑ?' "$ORANGE" + exit 0 +fi -[[ $cache_flag == 'cache' ]] && printf '%s' "$full_text" >"$CACHE" emit "$full_text" "$short_text" "$color" diff --git a/linux_configuration/i3blocks/shutdown_countdown.sh b/linux_configuration/i3blocks/shutdown_countdown.sh index 59f587b8..fd77ebc5 100755 --- a/linux_configuration/i3blocks/shutdown_countdown.sh +++ b/linux_configuration/i3blocks/shutdown_countdown.sh @@ -2,15 +2,28 @@ # Shutdown countdown status script for i3blocks. # Shows the exact absolute time of the next enforced shutdown, or the # overridden time if a shutdown-override-manager.sh window rescues it. +# +# The applied time and the still-unearned earners come from daily-limits' +# cache ($XDG_RUNTIME_DIR/daily-limits.json, rewritten every 60 s); this +# script owns no earner logic. The cache is re-parsed with jq only when its +# mtime changes, the rendered result is kept in a small file, so a steady-state +# tick forks nothing. A missing or >180 s old cache shows a "โฑ?" marker (and +# the config-derived time), never old cache numbers. Left-click opens +# `daily-limits --gui`. set -euo pipefail SHUTDOWN_CONFIG=${SHUTDOWN_CONFIG:-/etc/shutdown-schedule.conf} SKIP_DATES_FILE=${SKIP_DATES_FILE:-/etc/shutdown-skip-dates} OVERRIDES_FILE=${OVERRIDES_FILE:-/etc/shutdown-schedule-overrides.conf} -BUDGET_API=${GAMING_BUDGET_API:-http://127.0.0.1:8000/api/budget} -# earned_time's SHUTDOWN_CEILING_MINUTES (23:00): no earner pushes past it. -SHUTDOWN_CEILING_MINUTES=${SHUTDOWN_CEILING_MINUTES:-1380} +LIMITS_CACHE=${DAILY_LIMITS_CACHE:-${XDG_RUNTIME_DIR:-/tmp}/daily-limits.json} +RENDER_CACHE=${DAILY_LIMITS_RENDER_CACHE:-${XDG_RUNTIME_DIR:-/tmp}/i3blocks-shutdown.render} +readonly STALE_AFTER_SECONDS=180 + +# Left-click: open the daily-limits popup (a fork only on click). +if [[ ${BLOCK_BUTTON:-0} -eq 1 ]] && command -v daily-limits >/dev/null 2>&1; then + daily-limits --gui >/dev/null 2>&1 & +fi # Function to show error state in i3blocks and exit show_error() { @@ -151,31 +164,71 @@ if override_match=$(find_override_covering "$shutdown_epoch_today"); then exit 0 fi -# Earners not yet done today, rendered like gaming_budget.sh: " +๐Ÿงฉ1h +๐Ÿ“–1h". -# The enforcer's /api/budget reports gaming seconds per earner; every -# earned_time earner moves shutdown by the same amount, so that one source -# serves both blocks. Past the earned_time shutdown ceiling the extra is lost, -# hence the "โ†’max" note. Server down = no hint: the time above is still right. -earner_hint() { - local headroom_seconds=$(((SHUTDOWN_CEILING_MINUTES - shutdown_time_minutes) * 60)) - local ceiling_hhmm budget_json - ((headroom_seconds > 0)) || return 0 - command -v curl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 || return 0 - budget_json=$(curl -fsS --max-time 2 "$BUDGET_API" 2>/dev/null) || return 0 - printf -v ceiling_hhmm '%02d:%02d' $((SHUTDOWN_CEILING_MINUTES / 60)) $((SHUTDOWN_CEILING_MINUTES % 60)) - jq -r --argjson headroom "$headroom_seconds" --arg ceiling "$ceiling_hhmm" ' - def bonus_hm: (. / 60 | round) as $m +# One jq pass renders three lines: generated_at, the applied shutdown time +# (falling back to the resolved one) and the hint for earners still in `todo`, +# e.g. " +๐Ÿงฉ1h +๐Ÿ“–?1h โ†’max 23:00" ("?" = status unknown, still counted). "โ†’max" +# appears once the cumulative `shutdown_after` of the todo list reaches the ceiling. +# Re-run only when the cache file is newer than the render. +render_limits() { + local rendered + command -v jq >/dev/null 2>&1 || return 1 + rendered=$(jq -r ' + def bonus_hm: . as $m | if $m < 60 then "\($m)m" else "\($m / 60 | floor)h" + ($m % 60 | if . == 0 then "" else "\(.)" | if length < 2 then "0" + . else . end end) end; {workout: "๐Ÿ’ช", leetcode: "๐Ÿงฉ", reading: "๐Ÿ“–", anki: "๐Ÿ—‚", automation: "โš™"} as $icon - | select(.ok == true) - | [.rules.earners[]? | select((.earned_seconds // 0) == 0 and .bonus_seconds > 0)] - | select(length > 0) - | " " + (map("+\($icon[.name] // .label)\(.bonus_seconds | bonus_hm)") | join(" ")) - + (if (map(.bonus_seconds) | add) > $headroom then " โ†’max \($ceiling)" else "" end) - ' <<<"$budget_json" 2>/dev/null || true + | (.earners | map({(.name): .}) | add // {}) as $by + | (.shutdown.applied // .shutdown.resolved) as $time + | (.shutdown.ceiling) as $ceiling + | (if $time < $ceiling and (.todo | length) > 0 + then " " + (.todo | map("+\($icon[.name] // .label)\(if .status == "unknown" then "?" else "" end)\($by[.name].shutdown_minutes // 0 | bonus_hm)") | join(" ")) + + (if (.todo | map(.shutdown_after) | max) >= $ceiling then " โ†’max \($ceiling)" else "" end) + else "" end) as $hint + | .generated_at, $time, $hint' "$LIMITS_CACHE" 2>/dev/null) || return 1 + printf '%s\n' "$rendered" >"$RENDER_CACHE" } +# Sets limits_state (fresh|stale|missing|bad), limits_time, limits_hint, limits_age. +limits_state=missing +limits_time='' +limits_hint='' +limits_age=0 +load_limits() { + local gen='' + [[ -r $LIMITS_CACHE ]] || return 0 + limits_state=bad + if [[ $LIMITS_CACHE -nt $RENDER_CACHE ]]; then + render_limits || return 0 + fi + { + read -r gen + read -r limits_time + IFS= read -r limits_hint || true + } <"$RENDER_CACHE" 2>/dev/null || return 0 + [[ $gen =~ ^[0-9]+$ && $limits_time =~ ^[0-9]{2}:[0-9]{2}$ ]] || return 0 + limits_age=$((now_epoch - gen)) + if ((limits_age > STALE_AFTER_SECONDS || limits_age < -STALE_AFTER_SECONDS)); then + limits_state=stale + else + limits_state=fresh + fi +} + +load_limits + +printf -v shutdown_label '%(%H:%M)T' "$shutdown_epoch_today" +hint='' +case $limits_state in +fresh) + shutdown_label=$limits_time + hint=$limits_hint + shutdown_time_minutes=$((10#${limits_time%:*} * 60 + 10#${limits_time#*:})) + ;; +stale) hint=" โฑ? $((limits_age / 60))m" ;; +missing) hint=' โฑ?' ;; +bad) hint=' โฑ!' ;; +esac + minutes_until_shutdown=$((shutdown_time_minutes - current_time_minutes)) if [[ $minutes_until_shutdown -le 30 ]]; then @@ -188,6 +241,6 @@ else color="#6272A4" fi -echo "โป $(format_hhmm "$shutdown_epoch_today")$(earner_hint)" +echo "โป ${shutdown_label}${hint}" echo "โป" echo "$color" diff --git a/linux_configuration/tests/test_i3blocks_efficiency.sh b/linux_configuration/tests/test_i3blocks_efficiency.sh index 1d75b633..86409e40 100755 --- a/linux_configuration/tests/test_i3blocks_efficiency.sh +++ b/linux_configuration/tests/test_i3blocks_efficiency.sh @@ -55,13 +55,10 @@ count_execs() { grep -c 'execve(' "$log_file" } -# A dead budget server: shutdown_countdown.sh's earner hint must never reach -# the real enforcer from a test, and with no hint it runs no jq either. -cat >"$BIN_DIR/curl" <<'EOF' -#!/bin/bash -exit 7 -EOF -chmod +x "$BIN_DIR/curl" +# shutdown_countdown.sh renders daily-limits' cache; keep these checks off the +# machine's real $XDG_RUNTIME_DIR files. +export DAILY_LIMITS_CACHE="$TMP_DIR/daily-limits.json" +export DAILY_LIMITS_RENDER_CACHE="$TMP_DIR/shutdown.render" cat >"$BIN_DIR/pacman" <<'EOF' #!/bin/bash @@ -193,14 +190,17 @@ shutdown_overrides_empty="$TMP_DIR/shutdown-overrides-empty.conf" # is 21:00. The block shows the exact clock time of the shutdown, not a # relative countdown. shutdown_countdown_epoch=$(epoch_utc '2026-05-01 19:30:00') +printf '{"date":"2026-05-01","generated_at":%s,"shutdown":{"applied":"21:00","resolved":"21:00","floor":"20:00","ceiling":"23:00"},"gaming":{"budget_minutes":180,"used_minutes":null,"ceiling_minutes":480},"earners":[],"todo":[]}\n' \ + "$shutdown_countdown_epoch" >"$DAILY_LIMITS_CACHE" shutdown_countdown_output=$(TZ=UTC NOW_EPOCH="$shutdown_countdown_epoch" SHUTDOWN_CONFIG="$shutdown_config_file" OVERRIDES_FILE="$shutdown_overrides_empty" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh") assert_equals 'โป 21:00' "$(printf '%s\n' "$shutdown_countdown_output" | sed -n '1p')" \ 'shutdown countdown should show the exact clock time of the next shutdown' assert_equals '#F1FA8C' "$(printf '%s\n' "$shutdown_countdown_output" | sed -n '3p')" \ 'shutdown countdown should show yellow for two hours or less remaining' -# bash itself plus the one curl for the earner hint; no date helpers. -assert_le "$(count_execs "$I3BLOCKS_DIR/shutdown_countdown.sh")" 2 \ - 'shutdown countdown should avoid date helpers in the hot path' +# The run above rendered the cache; with the cache file unchanged a tick is +# bash alone: no jq, no curl, no date helpers. +assert_le "$(NOW_EPOCH="$shutdown_countdown_epoch" SHUTDOWN_CONFIG="$shutdown_config_file" OVERRIDES_FILE="$shutdown_overrides_empty" TZ=UTC count_execs "$I3BLOCKS_DIR/shutdown_countdown.sh")" 1 \ + 'shutdown countdown should fork nothing once the cache is rendered' shutdown_window_epoch=$(epoch_utc '2026-05-01 21:15:00') shutdown_window_output=$(TZ=UTC NOW_EPOCH="$shutdown_window_epoch" SHUTDOWN_CONFIG="$shutdown_config_file" OVERRIDES_FILE="$shutdown_overrides_empty" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh") @@ -225,9 +225,12 @@ assert_equals '#50FA7B' "$(printf '%s\n' "$shutdown_override_output" | sed -n '3 shutdown_minutes_file="$TMP_DIR/shutdown-schedule-minutes.conf" printf '%s\n' MON_WED_MINUTES=1380 THU_SUN_MINUTES=1110 MORNING_END_MINUTES=300 \ MON_WED_HOUR=23 THU_SUN_HOUR=18 MORNING_END_HOUR=5 >"$shutdown_minutes_file" +# No cache here: the config itself is under test, so the block falls back to it +# and marks the missing cache. +rm -f "$DAILY_LIMITS_CACHE" shutdown_minutes_output=$(TZ=UTC NOW_EPOCH="$(epoch_utc '2026-05-01 18:15:00')" SHUTDOWN_CONFIG="$shutdown_minutes_file" OVERRIDES_FILE="$shutdown_overrides_empty" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh") -assert_equals 'โป 18:30' "$(printf '%s\n' "$shutdown_minutes_output" | sed -n '1p')" \ - 'shutdown countdown should show the minute-precision shutdown time' +assert_equals 'โป 18:30 โฑ?' "$(printf '%s\n' "$shutdown_minutes_output" | sed -n '1p')" \ + 'shutdown countdown should show the minute-precision config time when the cache is missing' shutdown_minutes_output=$(TZ=UTC NOW_EPOCH="$(epoch_utc '2026-05-01 18:31:00')" SHUTDOWN_CONFIG="$shutdown_minutes_file" OVERRIDES_FILE="$shutdown_overrides_empty" PATH="$BIN_DIR:$PATH" bash "$I3BLOCKS_DIR/shutdown_countdown.sh") assert_equals 'โป SHUTDOWN' "$(printf '%s\n' "$shutdown_minutes_output" | sed -n '1p')" \ 'shutdown countdown should be due once the shutdown minute has passed' diff --git a/linux_configuration/tests/test_i3blocks_gaming_budget.sh b/linux_configuration/tests/test_i3blocks_gaming_budget.sh index b1615382..4c64aa29 100755 --- a/linux_configuration/tests/test_i3blocks_gaming_budget.sh +++ b/linux_configuration/tests/test_i3blocks_gaming_budget.sh @@ -1,7 +1,8 @@ #!/bin/bash -# Behaviour tests for the i3blocks gaming_budget block: text, colour -# thresholds, unearned-bonus suffix and every failure state. curl is stubbed on -# PATH to serve a fixture file, so nothing talks to the real enforcer. +# Behaviour tests for the i3blocks gaming_budget block: it renders daily-limits' +# cache ($DAILY_LIMITS_CACHE). Covers text, colour thresholds, the not-done +# earner suffix (todo and unknown), the gaming-day note, the stale/missing/ +# garbage states and the "re-parse only when the cache changes" contract. set -euo pipefail @@ -32,98 +33,104 @@ assert_eq() { printf '{}' | jq -e . >/dev/null 2>&1 || fail 'a working jq is required for the gaming_budget tests (pacman -S jq)' -# Stub curl: print $FIXTURE, or fail like `curl -f` on a dead server. -BIN_DIR="$TMP_DIR/bin" -mkdir -p "$BIN_DIR" -cat >"$BIN_DIR/curl" <<'STUB' -#!/bin/bash -[[ -r ${FIXTURE:-} ]] || exit 7 -cat "$FIXTURE" -STUB -chmod +x "$BIN_DIR/curl" +CACHE="$TMP_DIR/daily-limits.json" +RENDER="$TMP_DIR/render" +NOW=1790000000 -FIXTURE="$TMP_DIR/budget.json" -CACHE_FILE="$TMP_DIR/cache" -CONFIG_FILE="$TMP_DIR/config.json" - -# write_budget USED BUDGET REMAINING BLOCKED WORKOUT LEETCODE READING -write_budget() { - printf '{"ok":true,"state_status":"ok","today":{"seconds_used":%s,"budget_seconds":%s,"seconds_remaining":%s,"blocked":%s},"rules":{"bonuses":{"base":18000,"workout":%s,"leetcode":%s,"reading":%s}}}\n' \ - "$@" >"$FIXTURE" +# write_cache USED BUDGET GENERATED_AT GAMING_DAY EARNER... where EARNER is +# NAME:STATUS:MINUTES. USED may be "null". +write_cache() { + local used=$1 budget=$2 generated=$3 day=$4 + shift 4 + local rows=() name status minutes spec + for spec in "$@"; do + IFS=: read -r name status minutes <<<"$spec" + rows+=("{\"name\":\"$name\",\"label\":\"$name\",\"status\":\"$status\",\"shutdown_minutes\":$minutes,\"gaming_minutes\":$minutes}") + done + local IFS=, + printf '{"date":"2026-10-09","generated_at":%s,"shutdown":{"applied":"20:00","resolved":"20:00","floor":"20:00","ceiling":"23:00"},"gaming":{"budget_minutes":%s,"used_minutes":%s,"ceiling_minutes":480,"day":"%s"},"earners":[%s],"todo":[]}\n' \ + "$generated" "$budget" "$used" "$day" "${rows[*]}" >"$CACHE" + rm -f "$RENDER" # force a re-render for the new content } - run_block() { - PATH="$BIN_DIR:$PATH" FIXTURE="$FIXTURE" GAMING_BUDGET_CACHE="$CACHE_FILE" \ - GAMING_BUDGET_CONFIG="$CONFIG_FILE" bash "$BLOCK" + NOW_EPOCH="$NOW" DAILY_LIMITS_CACHE="$CACHE" DAILY_LIMITS_RENDER_CACHE="$RENDER" bash "$BLOCK" } line() { run_block | sed -n "${1}p" } -printf 'Checking the normal day: time left, used/total, missing bonus...\n' -write_budget 21600.3 28800 7199.7 false 7200 3600 0 +D=2026-10-09 + +printf 'Checking the normal day: time left, used/total, not-done bonus...\n' +write_cache 360 480 "$NOW" "$D" workout:done:120 leetcode:done:60 reading:todo:60 assert_eq '๐ŸŽฎ 2h00 left ยท 6h00/8h00 +๐Ÿ“–1h' "$(line 1)" 'full text' assert_eq '๐ŸŽฎ 2h00' "$(line 2)" 'short text' assert_eq '#50FA7B' "$(line 3)" 'green above 1h' printf 'Checking colour thresholds follow the enforcer warn_at...\n' -write_budget 25800 28800 3000 false 7200 3600 3600 +write_cache 430 480 "$NOW" "$D" workout:done:120 assert_eq '#F1FA8C' "$(line 3)" 'yellow at <=1h' assert_eq '๐ŸŽฎ 0h50 left ยท 7h10/8h00' "$(line 1)" 'no suffix when every bonus is earned' -write_budget 27600 28800 1200 false 7200 3600 3600 +write_cache 460 480 "$NOW" "$D" assert_eq '#FFB86C' "$(line 3)" 'orange at <=30m' -write_budget 28500 28800 300 false 7200 3600 3600 +write_cache 475 480 "$NOW" "$D" assert_eq '#FF5555' "$(line 3)" 'red at <=10m' printf 'Checking the blocked state...\n' -write_budget 18000 18000 0 true 0 0 0 -assert_eq '๐ŸŽฎ BLOCKED ยท 5h00/5h00 +๐Ÿ’ช2h +๐Ÿงฉ1h +๐Ÿ“–1h' "$(line 1)" 'blocked full text lists what can still be earned' +write_cache 300 300 "$NOW" "$D" workout:todo:120 leetcode:todo:60 reading:todo:60 +assert_eq '๐ŸŽฎ BLOCKED ยท 5h00/5h00 +๐Ÿ’ช2h +๐Ÿงฉ1h +๐Ÿ“–1h' "$(line 1)" 'blocked text lists what can still be earned' assert_eq '๐ŸŽฎ BLOCKED' "$(line 2)" 'blocked short text' assert_eq '#FF5555' "$(line 3)" 'blocked is red' -write_budget 18100 18000 -100 false 7200 3600 3600 -assert_eq '๐ŸŽฎ BLOCKED' "$(line 2)" 'overspent counts as blocked even before the flag flips' +write_cache 302 300 "$NOW" "$D" +assert_eq '๐ŸŽฎ BLOCKED' "$(line 2)" 'overspent counts as blocked' -printf 'Checking bonus sizes come from the enforcer config...\n' -printf '{"workout_bonus_seconds":5400,"leetcode_bonus_seconds":0}\n' >"$CONFIG_FILE" -write_budget 0 18000 18000 false 0 0 0 -assert_eq '๐ŸŽฎ 5h00 left ยท 0h00/5h00 +๐Ÿ’ช1h30 +๐Ÿ“–1h' "$(line 1)" 'configured sizes, zero-size bonus omitted' -rm -f "$CONFIG_FILE" +printf 'Checking earner icons, labels, minutes and the unknown marker...\n' +write_cache 0 240 "$NOW" "$D" workout:done:120 leetcode:todo:60 reading:todo:60 anki:todo:30 automation:todo:30 piano:todo:90 +assert_eq '๐ŸŽฎ 4h00 left ยท 0h00/4h00 +๐Ÿงฉ1h +๐Ÿ“–1h +๐Ÿ—‚30m +โš™30m +piano1h30' "$(line 1)" \ + 'cache order, done omitted, label for an unmapped earner' +write_cache 0 240 "$NOW" "$D" leetcode:unknown:60 anki:unknown:30 reading:done:60 +assert_eq '๐ŸŽฎ 4h00 left ยท 0h00/4h00 +๐Ÿงฉ?1h +๐Ÿ—‚?30m' "$(line 1)" \ + 'unknown earners are shown with a ? marker, not hidden' -printf 'Checking a daemon that lists the registry (.rules.earners)...\n' -# earner NAME LABEL EARNED SIZE -> one JSON object for .rules.earners. -earner() { - jq -nc --arg n "$1" --arg l "$2" --argjson e "$3" --argjson s "$4" \ - '{name: $n, label: $l, earned_seconds: $e, bonus_seconds: $s}' -} -jq -n --argjson earners "[$(earner workout workout 7200 7200),$(earner leetcode LeetCode 0 3600),$(earner reading reading 0 3600),$(earner anki Anki 0 1800),$(earner automation Automation 0 1800),$(earner piano Piano 0 5400)]" \ - '{ok: true, state_status: "ok", - today: {seconds_used: 0, budget_seconds: 14400, seconds_remaining: 14400, blocked: false}, - rules: {bonuses: {}, earners: $earners}}' >"$FIXTURE" -printf '{"leetcode_bonus_seconds":0}\n' >"$CONFIG_FILE" -assert_eq '๐ŸŽฎ 4h00 left ยท 0h00/4h00 +๐Ÿงฉ1h +๐Ÿ“–1h +๐Ÿ—‚30m +โš™30m +Piano1h30' "$(line 1)" \ - 'registry order, earned omitted, sizes from the daemon not the config, label for an unmapped earner' -rm -f "$CONFIG_FILE" +printf 'Checking used_minutes null shows only the budget...\n' +write_cache null 180 "$NOW" "$D" leetcode:todo:60 +assert_eq '๐ŸŽฎ budget 3h00 +๐Ÿงฉ1h' "$(line 1)" 'budget only' +assert_eq '๐ŸŽฎ 3h00' "$(line 2)" 'budget only short text' -printf 'Checking a dead server shows the cached value as stale...\n' -write_budget 21600 28800 7200 false 7200 3600 0 -run_block >/dev/null -rm -f "$FIXTURE" -assert_eq '๐ŸŽฎ 2h00 left ยท 6h00/8h00 +๐Ÿ“–1h (stale)' "$(line 1)" 'stale text' +printf 'Checking the gaming day shows only when it differs from the date...\n' +write_cache 60 180 "$NOW" 2026-10-08 leetcode:todo:60 +assert_eq '๐ŸŽฎ 2h00 left ยท 1h00/3h00 (10-08) +๐Ÿงฉ1h' "$(line 1)" 'previous gaming day noted' +write_cache 60 180 "$NOW" "$D" leetcode:todo:60 +assert_eq '๐ŸŽฎ 2h00 left ยท 1h00/3h00 +๐Ÿงฉ1h' "$(line 1)" 'same day: no note' + +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' assert_eq '#FFB86C' "$(line 3)" 'stale is orange' -rm -f "$CACHE_FILE" -assert_eq '๐ŸŽฎ budget server down' "$(line 1)" 'no cache: server down' -assert_eq '#FF5555' "$(line 3)" 'server down is red' +write_cache 60 180 "$((NOW - 180))" "$D" +assert_eq '๐ŸŽฎ 2h00 left ยท 1h00/3h00' "$(line 1)" '180 s is still fresh' +rm -f "$CACHE" +assert_eq '๐ŸŽฎ โฑ? no data' "$(line 1)" 'missing cache' +assert_eq '๐ŸŽฎ โฑ?' "$(line 2)" 'missing cache short text' -printf 'Checking error states are red and never overwrite the cache...\n' -printf 'kept' >"$CACHE_FILE" -printf '{"ok":false,"state_status":"corrupt"}\n' >"$FIXTURE" -assert_eq '๐ŸŽฎ state corrupt' "$(line 1)" 'bad state_status' -assert_eq '#FF5555' "$(line 3)" 'bad state is red' -printf '{"ok":false,"state_status":"ok"}\n' >"$FIXTURE" -assert_eq '๐ŸŽฎ budget error' "$(line 1)" 'ok:false' -printf 'not json' >"$FIXTURE" -assert_eq '๐ŸŽฎ unreadable budget' "$(line 1)" 'garbage body' -assert_eq 'kept' "$(<"$CACHE_FILE")" 'error states must leave the cache alone' +printf 'Checking an unreadable cache is red...\n' +printf 'not json' >"$CACHE" +rm -f "$RENDER" +assert_eq '๐ŸŽฎ 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 180 "$NOW" "$D" +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 'All gaming_budget checks passed.\n' diff --git a/linux_configuration/tests/test_i3blocks_shutdown_hint.sh b/linux_configuration/tests/test_i3blocks_shutdown_hint.sh index f5b363fa..0e013c66 100755 --- a/linux_configuration/tests/test_i3blocks_shutdown_hint.sh +++ b/linux_configuration/tests/test_i3blocks_shutdown_hint.sh @@ -1,8 +1,8 @@ #!/bin/bash -# Behaviour tests for shutdown_countdown.sh's earner hint: which earners are -# listed, their amounts, the 23:00 ceiling note, and the states that show no -# hint. curl is stubbed on PATH to serve a fixture, so nothing talks to the -# real enforcer. +# Behaviour tests for shutdown_countdown.sh's rendering of daily-limits' cache +# ($DAILY_LIMITS_CACHE): the applied time, the unearned-earner hint (todo and +# unknown), the ceiling note driven by `shutdown_after`, and the stale/missing/ +# garbage markers. set -euo pipefail @@ -30,68 +30,79 @@ assert_eq() { printf '{}' | jq -e . >/dev/null 2>&1 || fail 'a working jq is required for the shutdown hint tests (pacman -S jq)' -# Stub curl: print $FIXTURE, or fail like `curl -f` on a dead server. -BIN_DIR="$TMP_DIR/bin" -mkdir -p "$BIN_DIR" -cat >"$BIN_DIR/curl" <<'STUB' -#!/bin/bash -[[ -r ${FIXTURE:-} ]] || exit 7 -cat "$FIXTURE" -STUB -chmod +x "$BIN_DIR/curl" - -FIXTURE="$TMP_DIR/budget.json" +CACHE="$TMP_DIR/daily-limits.json" +RENDER="$TMP_DIR/render" SCHEDULE="$TMP_DIR/schedule.conf" NO_OVERRIDES="$TMP_DIR/overrides.conf" : >"$NO_OVERRIDES" # 2026-05-01 is a Friday, so THU_SUN_MINUTES applies. NOW=$(TZ=UTC date -d '2026-05-01 18:00:00' +%s) - -# write_earners NAME:EARNED:BONUS ... -- the /api/budget earners list. -write_earners() { - local rows=() name earned bonus +# write_cache APPLIED GENERATED_AT TODO... where TODO is NAME:STATUS:MINUTES:AFTER. +# APPLIED may be "null" (then resolved = 20:00 is shown). Each todo entry is also +# an earner, carrying its shutdown_minutes. +write_cache() { + local applied=$1 generated=$2 + shift 2 + [[ $applied == null ]] || applied="\"$applied\"" + local earners=() todos=() name status minutes after spec for spec in "$@"; do - IFS=: read -r name earned bonus <<<"$spec" - rows+=("{\"name\":\"$name\",\"label\":\"$name\",\"earned_seconds\":$earned,\"bonus_seconds\":$bonus}") + IFS=: read -r name status minutes after <<<"$spec" + earners+=("{\"name\":\"$name\",\"label\":\"$name\",\"status\":\"$status\",\"shutdown_minutes\":$minutes,\"gaming_minutes\":$minutes}") + todos+=("{\"name\":\"$name\",\"label\":\"$name\",\"status\":\"$status\",\"shutdown_after\":\"$after\"}") done local IFS=, - printf '{"ok":true,"rules":{"earners":[%s]}}\n' "${rows[*]}" >"$FIXTURE" + printf '{"date":"2026-05-01","generated_at":%s,"shutdown":{"applied":%s,"resolved":"20:00","floor":"20:00","ceiling":"23:00"},"gaming":{"budget_minutes":180,"used_minutes":null,"ceiling_minutes":480,"day":"2026-05-01"},"earners":[%s],"todo":[%s]}\n' \ + "$generated" "$applied" "${earners[*]}" "${todos[*]}" >"$CACHE" + rm -f "$RENDER" # force a re-render for the new content } # first_line SHUTDOWN_MINUTES -- the block's full_text at 18:00. first_line() { printf 'THU_SUN_MINUTES=%s\nMON_WED_MINUTES=%s\nMORNING_END_MINUTES=300\n' \ "$1" "$1" >"$SCHEDULE" - TZ=UTC NOW_EPOCH="$NOW" PATH="$BIN_DIR:$PATH" FIXTURE="$FIXTURE" \ + TZ=UTC NOW_EPOCH="$NOW" DAILY_LIMITS_CACHE="$CACHE" DAILY_LIMITS_RENDER_CACHE="$RENDER" \ SHUTDOWN_CONFIG="$SCHEDULE" OVERRIDES_FILE="$NO_OVERRIDES" \ SKIP_DATES_FILE=/dev/null bash "$BLOCK" | sed -n 1p } -printf 'Checking unearned earners are listed with icon and amount...\n' -write_earners workout:7200:7200 leetcode:0:3600 reading:0:3600 anki:0:1800 automation:0:1800 -assert_eq 'โป 20:00 +๐Ÿงฉ1h +๐Ÿ“–1h +๐Ÿ—‚30m +โš™30m' "$(first_line 1200)" \ - 'earned ones hidden, the rest listed; 3h fits under 23:00' +printf 'Checking not-done earners are listed with icon and amount...\n' +write_cache 20:00 "$NOW" leetcode:todo:60:21:00 reading:todo:60:22:00 anki:todo:30:22:30 automation:todo:30:23:00 +assert_eq 'โป 20:00 +๐Ÿงฉ1h +๐Ÿ“–1h +๐Ÿ—‚30m +โš™30m โ†’max 23:00' "$(first_line 1200)" \ + 'cumulative shutdown_after reaching 23:00 gives the ceiling note' +write_cache 20:00 "$NOW" leetcode:todo:60:21:00 reading:todo:60:22:00 +assert_eq 'โป 20:00 +๐Ÿงฉ1h +๐Ÿ“–1h' "$(first_line 1200)" 'below the ceiling: no note' -printf 'Checking the ceiling note once the bonuses overflow 23:00...\n' -assert_eq 'โป 22:00 +๐Ÿงฉ1h +๐Ÿ“–1h +๐Ÿ—‚30m +โš™30m โ†’max 23:00' "$(first_line 1320)" \ - '3h of bonuses with 1h of headroom' +printf 'Checking unknown earners are shown with ? and counted...\n' +write_cache 20:00 "$NOW" leetcode:unknown:60:21:00 reading:todo:60:22:00 anki:unknown:30:23:00 +assert_eq 'โป 20:00 +๐Ÿงฉ?1h +๐Ÿ“–1h +๐Ÿ—‚?30m โ†’max 23:00' "$(first_line 1200)" \ + 'unknown marked, and its shutdown_after still drives โ†’max' -printf 'Checking nothing is listed at the ceiling or with all earned...\n' +printf 'Checking the applied time wins, resolved is the fallback...\n' +write_cache 21:30 "$NOW" +assert_eq 'โป 21:30' "$(first_line 1200)" 'applied time shown' +write_cache null "$NOW" +assert_eq 'โป 20:00' "$(first_line 1200)" 'applied null falls back to resolved' + +printf 'Checking nothing is listed at the ceiling or with all done...\n' +write_cache 23:00 "$NOW" leetcode:todo:60:23:00 assert_eq 'โป 23:00' "$(first_line 1380)" 'no headroom, no hint' -write_earners leetcode:3600:3600 reading:3600:3600 -assert_eq 'โป 20:00' "$(first_line 1200)" 'everything earned' +write_cache 20:00 "$NOW" +assert_eq 'โป 20:00' "$(first_line 1200)" 'everything done' -printf 'Checking odd amounts and unknown earners...\n' -write_earners leetcode:0:5400 newgate:0:900 +printf 'Checking odd amounts and unmapped earners...\n' +write_cache 20:00 "$NOW" leetcode:todo:90:21:30 newgate:todo:15:21:45 assert_eq 'โป 20:00 +๐Ÿงฉ1h30 +newgate15m' "$(first_line 1200)" \ - '1h30 padded like gaming_budget.sh; unknown earner falls back to label' + '1h30 padded like gaming_budget.sh; unmapped earner falls back to label' -printf 'Checking a dead or unhappy server shows the bare time...\n' -rm -f "$FIXTURE" -assert_eq 'โป 20:00' "$(first_line 1200)" 'server down' -printf '{"ok":false}\n' >"$FIXTURE" -assert_eq 'โป 20:00' "$(first_line 1200)" 'budget error' -printf 'not json\n' >"$FIXTURE" -assert_eq 'โป 20:00' "$(first_line 1200)" 'unreadable reply' +printf 'Checking stale, missing and unreadable caches show a marker...\n' +write_cache 21:30 "$((NOW - 181))" leetcode:todo:60:22:30 +assert_eq 'โป 20:00 โฑ? 3m' "$(first_line 1200)" 'stale: config time, no cache numbers' +write_cache 21:30 "$((NOW - 180))" +assert_eq 'โป 21:30' "$(first_line 1200)" '180 s is still fresh' +rm -f "$CACHE" +assert_eq 'โป 20:00 โฑ?' "$(first_line 1200)" 'missing cache' +printf 'not json\n' >"$CACHE" +rm -f "$RENDER" +assert_eq 'โป 20:00 โฑ!' "$(first_line 1200)" 'unreadable cache' printf 'All shutdown hint tests passed.\n'