aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/design/2026-06-29-waybar-timer-module-spec.org217
1 files changed, 217 insertions, 0 deletions
diff --git a/docs/design/2026-06-29-waybar-timer-module-spec.org b/docs/design/2026-06-29-waybar-timer-module-spec.org
new file mode 100644
index 0000000..4b0ed0e
--- /dev/null
+++ b/docs/design/2026-06-29-waybar-timer-module-spec.org
@@ -0,0 +1,217 @@
+#+TITLE: Waybar Timer Module (wtimer) — Design Spec
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-29
+
+* Goal
+
+One always-visible waybar module that keeps time four ways — countdown timer,
+wall-clock alarm, count-up stopwatch, and pomodoro — with several items running
+at once. The bar shows the most urgent item with a per-type glyph; the tooltip
+lists them all. Backed by a single =wtimer= script over a small JSON state file.
+notify fires on completion. fuzzel drives creation. No GTK app.
+
+Source task: archsetup =todo.org= "Waybar timer module" (=:waybar:=), including
+the folded roam-capture scope expansion (mode-selectable single panel,
+stopwatch, multiple simultaneous, per-mode hover text).
+
+* Scope
+
+** In
+- *Timer* — count down a duration, notify on elapse, then remove.
+- *Alarm* — fire at a wall-clock time, notify, then remove.
+- *Stopwatch* — count up from start; pause/resume; manual stop.
+- *Pomodoro* — work/break cycles (25/5, long break 15 after 4 works), auto-advance with a notify at each phase change, runs until cancelled.
+- *Multiple simultaneous* — N items of any mix held in state. Bar shows one primary item plus a =+N= badge; tooltip lists every item with its remaining/elapsed and label.
+- *Pause / resume* per item; *cancel* one or all.
+- *Interactions* — click to create (fuzzel), middle-click pause/resume primary, right-click cancel (fuzzel pick), scroll to cycle which item is primary.
+- *Per-type glyph + CSS state classes* (running / paused / urgent / break).
+- *Persistence across waybar restarts* (state file in the runtime dir).
+
+** Out (v1, note for later)
+- No GTK panel — waybar module + tooltip + fuzzel only.
+- No persistence across *reboot* (runtime-dir state clears). Alarms set before a reboot won't survive. Acceptable v1; revisit with =~/.local/state= + a catch-up-on-boot pass if wanted.
+- No sound selection per item (uses notify's type sound).
+- No history/stats of completed pomodoros beyond the current run's cycle count.
+
+* Architecture
+
+- =wtimer= — a single executable Python script in =hyprland/.local/bin/=. Chosen over POSIX sh (the other waybar backings) deliberately: the multi-item state machine, time arithmetic, pomodoro FSM, and JSON I/O are cleaner in Python, and it gives real line/branch *coverage numbers* (Craig asked for them). Precedent: pocketbook is Python in this repo.
+- *Pure core + thin IO shell.* All logic is pure functions taking =now= as a parameter (dependency-injected clock — satisfies testing.md: no recursion, no scope-shadowing, production reads =time.time()=, tests pass an explicit instant). The CLI layer does the IO: read state, call pure fns, write state, emit JSON, shell out to notify/fuzzel.
+- *State file*: =$XDG_RUNTIME_DIR/waybar/wtimer.json= (env override =WTIMER_STATE= for tests). Same runtime-dir convention as =sysmon-metric=.
+- *Heartbeat*: waybar calls =wtimer render= every 1s. =render= runs the tick logic first (detect elapsed items, fire notify, advance pomodoro, drop finished timers/alarms), then prints the waybar JSON. One entry point waybar polls; no separate daemon.
+- *Concurrency (BLOCKER from review).* The 1s =render= and the click/scroll handlers (=add=, =toggle=, =cancel=, =cycle=) are separate processes doing read-modify-write on the same state file. Without serialization, last-writer-wins drops a click's =add=, or clobbers render's "item removed/advanced" write so the same item ticks and notifies again next second. So every read-modify-write takes an exclusive =flock= on the state file for the whole cycle, and writes go through a temp file + =os.replace= (atomic), so a concurrent render never reads a half-written file. This is what actually makes "notify fires once" true — the mutation is only authoritative under the lock.
+- *State dir*: =render= and the mutating commands =mkdir -p= the state dir first (=$XDG_RUNTIME_DIR/waybar/= may not exist on a fresh boot).
+- *Clock injection everywhere*: =now= comes from =WTIMER_NOW= (epoch) if set, else =time.time()=. Pure fns take =now= as a parameter; the CLI seeds it from the env. This lets the CLI integration tests hit boundary instants (exactly-at-target), not just the pure-fn tests.
+- *Instant refresh*: after any mutating command, send waybar =SIGRTMIN+14= (the module's signal) so the bar updates immediately instead of lagging up to 1s. Faked in tests (=WTIMER_REFRESH= override, default =pkill -RTMIN+14 waybar=).
+
+* State model
+
+#+begin_src json
+{
+ "items": [
+ {"id": "1", "type": "timer", "label": "tea", "target": 1751240400, "duration": 300, "paused_left": null},
+ {"id": "2", "type": "alarm", "label": "", "target": 1751251200, "paused_left": null},
+ {"id": "3", "type": "stopwatch", "label": "", "start": 1751240000, "paused_elapsed": null},
+ {"id": "4", "type": "pomodoro", "label": "", "target": 1751241900, "phase": "work",
+ "cycle": 1, "work": 1500, "short": 300, "long": 900, "interval": 4, "paused_left": null}
+ ],
+ "primary": "1",
+ "seq": 4
+}
+#+end_src
+
+- =seq= is the monotonic id source (string ids).
+- *Paused* timer/pomodoro: =paused_left= holds seconds remaining; =target= ignored while paused; resume sets =target = now + paused_left=, =paused_left = null=.
+- *Paused* stopwatch: =paused_elapsed= holds elapsed seconds; resume sets =start = now - paused_elapsed=.
+- =primary= is the id the bar shows; =null= or stale → auto-select (below).
+
+* Display logic
+
+** Primary selection (bar text)
+1. If =primary= names a live item, show it.
+2. Else the running countdown (timer/alarm/pomodoro) with the smallest remaining.
+3. Else the first running stopwatch.
+4. Else idle (no items).
+
+** Bar text
+- =<glyph> <time>= for the primary, plus = +N= when N other items exist.
+- Idle: a dim timer glyph alone (or empty — decide at render; lean dim glyph so the module has a stable click target).
+- =time= formatting: =M:SS= under 1h, =H:MM:SS= at/over 1h. Stopwatch counts up; timer/alarm/pomodoro count down to target.
+- Paused item: prefix a pause glyph or rely on the =paused= class (CSS dims it).
+
+** Glyphs (nerd font; final codepoints verified live before merge)
+- timer 󰔛, alarm 󰀠, stopwatch 󱎫, pomodoro-work , pomodoro-break (coffee), paused 󰏤, idle 󰔛 (dim).
+- One glyph table at the top of the script so a live-render tweak is one edit.
+
+** Tooltip (all items)
+One line per item: =<glyph> <label-or-type> <remaining/elapsed> (<state>)=. Pomodoro line shows phase + cycle (e.g. =work 2/4=). Header line summarizes count. Empty state: "No timers".
+
+** CSS classes (the =alt=/=class= field)
+=timer= / =alarm= / =stopwatch= / =pomodoro-work= / =pomodoro-break=, plus =paused= and =urgent= (remaining < 60s). Drives color in style.css + both themes.
+
+* Commands (CLI)
+
+| Command | Effect |
+|---------------------------------+---------------------------------------------------------------------|
+| =wtimer render= | tick + emit waybar JSON (the heartbeat) |
+| =wtimer add timer <dur> [label]=| add a countdown (=dur= like =25m=, =90s=, =1h30m=, =5= → minutes) |
+| =wtimer add alarm <HH:MM> [lbl]=| add a wall-clock alarm (next occurrence of that time) |
+| =wtimer add stopwatch [label]= | start a count-up |
+| =wtimer add pomodoro [label]= | start a pomodoro at work phase |
+| =wtimer new= | fuzzel: pick type, prompt value, dispatch to =add= (thin wrapper) |
+| =wtimer toggle [id]= | pause/resume the item (default: primary) |
+| =wtimer cancel <id>= | remove one item |
+| =wtimer pick-cancel= | fuzzel: choose an item to cancel (right-click handler) |
+| =wtimer cancel-all= | clear all |
+| =wtimer cycle [next|prev]= | move the primary pointer across all items (incl. paused), state-list order, wrapping |
+
+Duration parse: =Nh=, =Nm=, =Ns= combos, or a bare integer = minutes. Reject
+unparseable input (exit non-zero, notify nothing). Alarm parse: =HH:MM= 24h; if
+that time today already passed, target tomorrow.
+
+* Notifications
+
+- Timer elapse: =notify alarm "Timer" "<label or duration> done" --persist=.
+- Alarm fire: =notify alarm "Alarm" "<HH:MM><, label>" --persist=.
+- Pomodoro phase change: =notify info "Pomodoro" "Work → short break (3/4)"= (no =--persist=; phase nudges shouldn't pile up), long-break and work-resume worded accordingly.
+- notify is faked on PATH in tests; assert type + that it fired once per event.
+
+* Pomodoro semantics
+
+- Defaults: work 25m, short 5m, long 15m, interval 4 (long break after every 4th work).
+- FSM: work → short → work → short → work → short → work → long → work …
+- =cycle= counts completed works in the current set (1..interval); resets after a long break.
+- Each phase elapse advances =phase=, recomputes =target=, fires the phase notify. Pomodoro never auto-removes; cancel ends it.
+
+* Waybar wiring
+
+** Module def (config) — signal 14 (next free; 8–13 used)
+#+begin_src json
+"custom/timer": {
+ "exec": "wtimer render",
+ "return-type": "json",
+ "interval": 1,
+ "signal": 14,
+ "on-click": "wtimer new",
+ "on-click-middle": "wtimer toggle",
+ "on-click-right": "wtimer pick-cancel",
+ "on-scroll-up": "wtimer cycle next",
+ "on-scroll-down": "wtimer cycle prev"
+}
+#+end_src
+
+** Position — right of the sysmon (battery/resource) module
+Insert =custom/timer= into =modules-right= immediately after =custom/sysmon=
+(between =custom/sysmon= and =custom/netspeed=). On screen that places it just
+right of the battery/resource readout.
+
+** Not collapsible — survives the right-side collapse
+The module *definition* lives in the canonical config object, and =waybar-collapse=
+only swaps the =modules-right= *array* in the runtime copy (which it seeds from
+canonical, so the def is always present). So making the timer non-collapsible is
+purely an array-membership change: add =custom/timer= to the =waybar-collapse=
+right *base set* so it stays listed when the right side collapses:
+- laptop: =["custom/arrow-right","custom/sysmon","custom/timer","tray","custom/date","custom/worldclock"]=
+- desktop: =["custom/arrow-right","custom/timer","tray","custom/date","custom/worldclock"]=
+Update the =tests/waybar-collapse= base-set expectations to match (TDD the change).
+
+* CSS
+
+Add =#custom-timer= plus the state classes to all three stylesheets. Keep the
+*selectors and structure* parallel across the three (what the theme-drift test
+checks); the actual color *values* are per-theme (dupre vs hudson) and differ by
+design, so this is structural parity, not byte-identity. Confirm against the real
+CSS files what the drift test compares before editing.
+- =hyprland/.config/waybar/style.css=
+- =hyprland/.config/themes/dupre/...= waybar css
+- =hyprland/.config/themes/hudson/...= waybar css
+Colors: normal = foreground; =urgent= = a warning hue (reuse the sysmon
+warn/crit palette); =paused= = dimmed; =pomodoro-break= = a calmer accent.
+
+* Testing plan (TDD)
+
+- Suite: =tests/wtimer/test_wtimer.py= (auto-discovered by =make test='s =tests/*/test_*.py= glob — no enumeration gap).
+- *Pure-function tests* (fast, the bulk), explicit injected =now=:
+ - =parse_duration=: =25m=, =90s=, =1h30m=, =5= (→min), =0=, negative, garbage, empty (Normal/Boundary/Error).
+ - =parse_alarm=: future today, already-passed-today → tomorrow, =00:00=, =23:59=, =24:00=/=12:60= invalid, non-=HH:MM=.
+ - =format_time=: 0, 59s, 60s, 3599s, 3600s, multi-hour, negative clamps to 0.
+ - =add_item= for each type; =seq= increments; ids unique.
+ - =tick=: timer not-yet-elapsed (no change), exactly-at-target, past-target (fires once, removed); alarm same; pomodoro work→short→…→long→work advance + cycle counting + the 4th-work→long boundary; paused items never tick; multiple items in one tick.
+ - =select_primary=: explicit primary, stale primary falls back, soonest-remaining rule, stopwatch-only, empty.
+ - =render_payload=: text/tooltip/class for each type + paused + urgent + =+N= badge + idle.
+ - =toggle= pause then resume round-trips remaining/elapsed exactly; =cycle= wraps; =cancel= / =cancel-all=.
+- *CLI integration tests* (subprocess, fakes on PATH, =WTIMER_NOW= to hit boundaries): =add= then =render= round-trip; =render= fires the faked =notify= once on an elapsed item and drops it; state file created if absent; *missing parent dir* created (fresh-boot case); corrupt/empty state file → treated as empty, no crash; mutating command sends the faked refresh signal.
+- *Concurrency test*: spawn overlapping =render= + a mutating command against one state file; assert no lost update (the added item survives) and exactly-once notify (no double-fire from a clobbered tick). This is the regression guard for the flock/atomic-write fix.
+- *Mocking boundary*: fake =notify=, =fuzzel=, =killall= on PATH (record calls); never mock the wtimer logic. Clock injected as a parameter.
+- *Coverage*: measure with =coverage.py= if present (target 90%+ on the logic per testing.md business-logic bar); report the actual number. If =coverage= is absent, report per-command/per-branch case coverage explicitly and flag the tool gap (verification.md).
+- =tests/waybar-collapse= base-set expectations updated for the new module.
+- =tests/= theme-drift check stays green (CSS parity).
+
+* Files touched
+
+dotfiles branch =waybar-timer-module=:
+- =hyprland/.local/bin/wtimer= (new, executable)
+- =tests/wtimer/test_wtimer.py= (new)
+- =hyprland/.config/waybar/config= (module def + modules-right position)
+- =hyprland/.local/bin/waybar-collapse= (base-set) + =tests/waybar-collapse/...= (expectations)
+- =hyprland/.config/waybar/style.css= + dupre + hudson waybar css (CSS)
+
+archsetup (main, at the end):
+- this spec
+- =todo.org= task closure
+
+* Resolved decisions (no approvals — my calls)
+
+- Python, not sh — testability + coverage; pocketbook precedent.
+- One =render= heartbeat (no daemon) — simplest, waybar already polls.
+- notify fires from =render='s tick, mutation guarantees once-only.
+- Primary = user-cycled, else soonest-remaining; =+N= badge for the rest.
+- Multiple simultaneous via tooltip list + badge (not a GTK panel) — keeps it "cool yet simple".
+- Pomodoro is one self-advancing item, not four chained timers.
+- Runtime-dir state (waybar-restart durable, not reboot durable) — v1.
+
+* Rollback
+
+All code on the dotfiles =waybar-timer-module= branch off =09815f3=. Squash-merge
+at the end; =git switch main && git branch -D waybar-timer-module= reverts cleanly
+if it goes sideways.