mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +01:00
i3blocks: focus_project block (active project)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
This commit is contained in:
parent
aa1cce0a8c
commit
ac9b0b7e97
@ -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: `🎯 <name> <minutes>/<daily_minutes>`, 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"
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
|
||||
89
linux_configuration/i3blocks/focus_project.sh
Executable file
89
linux_configuration/i3blocks/focus_project.sh
Executable file
@ -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"
|
||||
106
linux_configuration/tests/test_i3blocks_focus_project.sh
Executable file
106
linux_configuration/tests/test_i3blocks_focus_project.sh
Executable file
@ -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<b>' 5 120)"
|
||||
out=$(run_block)
|
||||
assert_eq 3 "$(printf '%s\n' "$out" | wc -l)" 'three lines only'
|
||||
assert_eq '🎯 Evil name<b> 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'
|
||||
Loading…
Reference in New Issue
Block a user