From ac9b0b7e9756b7fb104bb4b9f603206c0b254108 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 08:20:15 +0100 Subject: [PATCH] i3blocks: focus_project block (active project) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy --- .../i3blocks-focus-project-2026-11-02.json | 29 +++++ linux_configuration/i3blocks/config | 7 ++ linux_configuration/i3blocks/focus_project.sh | 89 +++++++++++++++ .../tests/test_i3blocks_focus_project.sh | 106 ++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 docs/superpowers/evidence/i3blocks-focus-project-2026-11-02.json create mode 100755 linux_configuration/i3blocks/focus_project.sh create mode 100755 linux_configuration/tests/test_i3blocks_focus_project.sh diff --git a/docs/superpowers/evidence/i3blocks-focus-project-2026-11-02.json b/docs/superpowers/evidence/i3blocks-focus-project-2026-11-02.json new file mode 100644 index 00000000..71b40965 --- /dev/null +++ b/docs/superpowers/evidence/i3blocks-focus-project-2026-11-02.json @@ -0,0 +1,29 @@ +{ + "intent": "kuhy sees the active focus project in i3bar: a focus_project block renders the focus field daily-limits now writes to its cache.", + "scope": [ + "linux_configuration/i3blocks/focus_project.sh, the [focus_project] stanza in linux_configuration/i3blocks/config, linux_configuration/tests/test_i3blocks_focus_project.sh", + "Non-goal: no focus logic here (focus_project.status() decides); daily-limits owns the cache" + ], + "changes": [ + "New block: `🎯 /`, green at the daily target, hidden with no project or a missing/stale/garbage cache; jq re-parse only on cache mtime change", + "Stanza has signal=11 so daily-limits can refresh it with pkill -RTMIN+11 -x i3blocks" + ], + "verification": [ + { + "command": "bash linux_configuration/tests/test_i3blocks_focus_project.sh", + "result": "pass", + "evidence": "focus_project block: all checks passed" + }, + { + "command": "bash ~/.config/i3blocks/focus_project.sh (live cache after daily-limits.service)", + "result": "pass", + "evidence": "🎯 HackerRank prep 0/120, #F1FA8C" + } + ], + "risks": [ + "The running i3bar loads the stanza only after its next restart; until then the poke is harmless (i3blocks 1.5 survived an unconfigured RTMIN+11)" + ], + "rollback": [ + "git revert the commit and remove the symlink ~/.config/i3blocks/focus_project.sh" + ] +} diff --git a/linux_configuration/i3blocks/config b/linux_configuration/i3blocks/config index da70f465..e5c5f66d 100644 --- a/linux_configuration/i3blocks/config +++ b/linux_configuration/i3blocks/config @@ -72,6 +72,13 @@ interval=30 command=~/.config/i3blocks/tutor.sh interval=30 +# Active focus project. signal=11: daily-limits sends `pkill -RTMIN+11 -x +# i3blocks` the moment the focus status changes; 30 s is only the fallback. +[focus_project] +command=~/.config/i3blocks/focus_project.sh +interval=30 +signal=11 + [issue_dashboard] command=~/.config/i3blocks/issue_dashboard.sh interval=30 diff --git a/linux_configuration/i3blocks/focus_project.sh b/linux_configuration/i3blocks/focus_project.sh new file mode 100755 index 00000000..b8050270 --- /dev/null +++ b/linux_configuration/i3blocks/focus_project.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# ============================================================================ +# Active focus project for i3blocks: `🎯 HackerRank prep 45/120` (minutes done +# today / daily target). Green once the daily target is reached, yellow while +# short of it, hidden when no project is active. +# +# Renders the `focus` field of daily-limits' cache ($XDG_RUNTIME_DIR/ +# daily-limits.json, rewritten every 60 s by its user timer) and owns no +# focus logic: focus_project.status() decides. The cache is re-parsed with jq +# only when its mtime changes, so a steady-state tick forks nothing. +# +# Refresh is event-driven: the block has `signal=11` in the i3blocks config and +# daily-limits sends `pkill -RTMIN+11 -x i3blocks` the moment the focus status +# changes (the 30 s interval is only the fallback). +# +# A missing, garbage or >180 s old cache hides the block: the neighbouring +# gaming_budget block already says "⏱?" for the same cache, and old minutes +# must never be shown. Left-click opens `daily-limits --gui`. +# ============================================================================ + +set -euo pipefail + +readonly LIMITS_CACHE="${DAILY_LIMITS_CACHE:-${XDG_RUNTIME_DIR:-/run/user/$UID}/daily-limits.json}" +readonly RENDER_CACHE="${DAILY_LIMITS_RENDER_CACHE:-${XDG_RUNTIME_DIR:-/run/user/$UID}/i3blocks-focus-project.render}" +readonly STALE_AFTER_SECONDS=180 + +# Dracula-ish palette, matching gaming_budget.sh. +readonly YELLOW='#F1FA8C' +readonly GREEN='#50FA7B' + +emit() { + # full_text, short_text, color: the three lines i3blocks reads. + printf '%s\n%s\n%s\n' "$1" "$2" "$3" +} + +# i3blocks' PATH has no ~/.local/bin, where daily-limits' install.sh puts it. +readonly DAILY_LIMITS_BIN=${DAILY_LIMITS_BIN:-$HOME/.local/bin/daily-limits} +if [[ ${BLOCK_BUTTON:-0} -eq 1 && -x $DAILY_LIMITS_BIN ]]; then + setsid -f "$DAILY_LIMITS_BIN" --gui >/dev/null 2>&1 +fi + +hide() { + emit '' '' "$YELLOW" + exit 0 +} + +# One jq pass renders four lines: generated_at, full text, short text, color. +# The name is untrusted display text: control characters become spaces so it +# can never add lines to the block's output. +render_focus() { + local rendered + rendered=$(jq -r --arg green "$GREEN" --arg yellow "$YELLOW" ' + (.generated_at | if type == "number" then floor else "none" end), + (.focus | if type == "object" and (.project | type) == "string" then + (.minutes // 0 | floor) as $m + | (.daily_minutes // 0 | floor) as $d + | ((.name // .project) | tostring | gsub("[\u0001-\u001f\u007f]"; " ")) as $name + | "🎯 \($name) \($m)/\($d)", "🎯 \($m)/\($d)", + (if $d > 0 and $m >= $d then $green else $yellow end) + else "", "", $yellow end)' "$LIMITS_CACHE" 2>/dev/null) || return 1 + printf '%s\n' "$rendered" >"$RENDER_CACHE" +} + +[[ -r $LIMITS_CACHE ]] || hide +if [[ $LIMITS_CACHE -nt $RENDER_CACHE ]]; then + command -v jq >/dev/null 2>&1 || hide + render_focus || hide +fi + +{ + read -r generated_at + read -r full_text + read -r short_text + read -r color +} <"$RENDER_CACHE" || hide + +if [[ -n ${NOW_EPOCH:-} ]]; then + now=$NOW_EPOCH +else + printf -v now '%(%s)T' -1 +fi +[[ $generated_at =~ ^-?[0-9]+$ ]] || hide +age=$((now - generated_at)) +if ((age > STALE_AFTER_SECONDS || age < -STALE_AFTER_SECONDS)); then + hide +fi + +emit "$full_text" "$short_text" "$color" diff --git a/linux_configuration/tests/test_i3blocks_focus_project.sh b/linux_configuration/tests/test_i3blocks_focus_project.sh new file mode 100755 index 00000000..af80a287 --- /dev/null +++ b/linux_configuration/tests/test_i3blocks_focus_project.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Behaviour tests for the i3blocks focus_project block: it renders the `focus` +# field of daily-limits' cache ($DAILY_LIMITS_CACHE). Covers the progress text, +# green once daily_minutes is reached, hidden with no project / no focus key / +# missing, stale or garbage cache, name sanitising, the click, 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/focus_project.sh" +TMP_DIR=$(mktemp -d) + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + if [[ "$1" != "$2" ]]; then + fail "$3 (expected '$1', actual '$2')" + fi +} + +printf '{}' | jq -e . >/dev/null 2>&1 || + fail 'a working jq is required for the focus_project tests (pacman -S jq)' + +CACHE="$TMP_DIR/daily-limits.json" +RENDER="$TMP_DIR/render" +NOW=1790000000 + +# write_cache GENERATED_AT FOCUS_JSON (FOCUS_JSON "" = no focus key at all) +write_cache() { + local focus=${2:+,\"focus\":$2} + printf '{"date":"2026-11-02","generated_at":%s%s}\n' "$1" "$focus" >"$CACHE" + rm -f "$RENDER" +} +focus_json() { # NAME MINUTES DAILY + jq -nc --arg n "$1" --argjson m "$2" --argjson d "$3" \ + '{project:"p",name:$n,minutes:$m,daily_minutes:$d,unlock_minutes:30,ends:"2026-11-05",replaces:["leetcode"]}' +} +run_block() { + NOW_EPOCH="$NOW" DAILY_LIMITS_CACHE="$CACHE" DAILY_LIMITS_RENDER_CACHE="$RENDER" \ + DAILY_LIMITS_BIN="$TMP_DIR/dl" bash "$BLOCK" +} +line() { sed -n "${1}p" <<<"$2"; } + +printf 'Checking progress and colour...\n' +write_cache "$NOW" "$(focus_json 'HackerRank prep' 45 120)" +out=$(run_block) +assert_eq '🎯 HackerRank prep 45/120' "$(line 1 "$out")" 'full text' +assert_eq '🎯 45/120' "$(line 2 "$out")" 'short text' +assert_eq '#F1FA8C' "$(line 3 "$out")" 'yellow below the daily target' + +write_cache "$NOW" "$(focus_json 'HackerRank prep' 120 120)" +out=$(run_block) +assert_eq '🎯 HackerRank prep 120/120' "$(line 1 "$out")" 'target reached text' +assert_eq '#50FA7B' "$(line 3 "$out")" 'green at daily_minutes' + +printf 'Checking the hidden states...\n' +write_cache "$NOW" '{"project":null,"minutes":0,"daily_minutes":0}' +out=$(run_block) +assert_eq '' "$(line 1 "$out")" 'null project hides' +assert_eq '' "$(line 2 "$out")" 'null project hides short text' +write_cache "$NOW" '' +assert_eq '' "$(line 1 "$(run_block)")" 'no focus key hides' +rm -f "$CACHE" "$RENDER" +assert_eq '' "$(line 1 "$(run_block)")" 'missing cache hides (the gaming block says why)' +write_cache $((NOW - 600)) "$(focus_json 'X' 5 120)" +assert_eq '' "$(line 1 "$(run_block)")" 'a stale cache hides, never old numbers' +printf '{' >"$CACHE" +rm -f "$RENDER" +assert_eq '' "$(line 1 "$(run_block)")" 'garbage cache hides' + +printf 'Checking a hostile name and a bad daily target...\n' +write_cache "$NOW" "$(focus_json $'Evil\nname' 5 120)" +out=$(run_block) +assert_eq 3 "$(printf '%s\n' "$out" | wc -l)" 'three lines only' +assert_eq '🎯 Evil name 5/120' "$(line 1 "$out")" 'newlines become spaces' +write_cache "$NOW" "$(focus_json 'Z' 5 0)" +assert_eq '#F1FA8C' "$(line 3 "$(run_block)")" 'daily 0 is never green' + +printf 'Checking the render cache is reused...\n' +write_cache "$NOW" "$(focus_json 'Keep' 10 120)" +run_block >/dev/null +printf '%s\n' "$NOW" '🎯 CACHED 1/1' '🎯 1/1' '#50FA7B' >"$RENDER" +touch -d '+1 second' "$RENDER" +assert_eq '🎯 CACHED 1/1' "$(line 1 "$(run_block)")" 'unchanged cache is not re-parsed' + +printf 'Checking a click opens the GUI...\n' +printf '#!/bin/bash\necho "$*" >>"%s/clicks"\n' "$TMP_DIR" >"$TMP_DIR/dl" +chmod +x "$TMP_DIR/dl" +BLOCK_BUTTON=1 run_block >/dev/null +for _ in 1 2 3 4 5 6 7 8 9 10; do + [[ -s "$TMP_DIR/clicks" ]] && break + sleep 0.1 +done +assert_eq '--gui' "$(cat "$TMP_DIR/clicks" 2>/dev/null)" 'click runs daily-limits --gui' + +printf 'focus_project block: all checks passed\n'