#+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 - =