1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
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.
|