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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
|
# Breaktimer in the Status Module Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give `breaktimer.sh` a config file, and let the quickshell drawer's status module show its phase and countdown and drive its verbs.
**Architecture:** Two independent repositories. In `breaktimer`, one sourced config file plus a `config` verb that prints the effective values, which is both the drawer-free way to check settings and the hook the test uses. In `quickshell`, three read-only `FileView`s in the `Status` singleton over the daemon's existing runtime files, one new row in the status page, and one line in the tile. Nothing in the shell ever writes a breaktimer file: the daemon owns them and the shell calls verbs.
**Tech Stack:** Bash 5 (daemon, config, test), QML / Quickshell 0.3.1 (`FileView`, `Process`), the repo's hand-rolled `Switch` and `Button` controls.
**Spec:** `docs/superpowers/specs/2026-09-18-breaktimer-status-design.md`
---
## Repositories
Two working directories. Tasks 1-4 are in the first, tasks 5-9 in the second.
- `~/Programming/GIT/breaktimer` — the daemon. Tasks 1-4.
- `~/Programming/GIT/quickshell` — the shell. Tasks 5-9.
Work them in order: the `config` verb from Task 2 is what makes the daemon's
settings observable, and nothing in the quickshell half depends on it, so the
two halves can also be done in either order if that is more convenient.
## File Structure
**`breaktimer` repo:**
| file | change | responsibility |
|---|---|---|
| `breaktimer.sh` | modify, 2 places | source a config file; print effective config |
| `test-breaktimer-config.sh` | create | the one runnable check for the config path |
| `README.md` | modify | document the config file |
**`quickshell` repo:**
| file | change | responsibility |
|---|---|---|
| `shared/Status.qml` | modify | read the three runtime files, expose three properties |
| `desktop/modules/status/BreakRow.qml` | create | the row: phase, countdown, pause switch, start/stop |
| `desktop/modules/status/StatusPage.qml` | modify | mount the row behind a divider |
| `desktop/modules/status/StatusTile.qml` | modify | one line in the priority chain |
| `desktop/modules/status/README.md` | modify | document the read direction |
`shared/Status.qml` is reached as `desktop/Status.qml`, a symlink. Edit the
file in `shared/`; both paths are the same file.
## A note on testing this
The bash half has a real runnable check, Task 3.
The QML half does not, and this plan does not invent one. This repository has
no QML test harness, and the honest check for a drawer row is looking at it.
Task 9 is a manual verification script with exact commands and exact expected
output, driven from the terminal against a live daemon. Do not skip it: every
control in the row is observable with `breaktimer.sh status`, so "it looked
right" is never the standard here.
---
### Task 1: Source a config file
**Files:**
- Modify: `~/Programming/GIT/breaktimer/breaktimer.sh:48` (after the config block, before `RUNTIME=`)
- [ ] **Step 1: Read the current boundary**
Run:
```bash
cd ~/Programming/GIT/breaktimer && sed -n '40,56p' breaktimer.sh
```
Expected: the tail of the configuration block, the `# ---...---` closing
comment on line 48, then `RUNTIME="${XDG_RUNTIME_DIR:-/tmp}"`.
- [ ] **Step 2: Insert the source line**
Replace the closing comment line:
```bash
# ------------------------------------
```
with:
```bash
# ------------------------------------
# Overrides, if any. Bash sources files natively, so there is no format and no
# parser: a syntax error here is a bash error at start, on stderr, which is the
# loudest possible failure and the right one. Read once, here, which means a
# change needs `breaktimer.sh restart` to take effect. The `run` subcommand
# re-execs this script through setsid, so the daemon reads it too.
CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/breaktimer.conf"
[ -f "$CONFIG_FILE" ] && . "$CONFIG_FILE"
```
- [ ] **Step 3: Verify the script still parses and still runs**
Run:
```bash
cd ~/Programming/GIT/breaktimer && bash -n breaktimer.sh && echo PARSE-OK
XDG_RUNTIME_DIR=$(mktemp -d) bash breaktimer.sh; echo "exit=$?"
```
Expected:
```
PARSE-OK
uso: breaktimer.sh [start|stop|restart|pause|resume|toggle|status]
exit=1
```
The usage line and `exit=1` are the unchanged no-argument behaviour. A config
file does not exist yet, so the `[ -f ]` test is false and nothing is sourced.
- [ ] **Step 4: Commit**
```bash
cd ~/Programming/GIT/breaktimer
git add breaktimer.sh
git commit -m "feat: read overrides from breaktimer.conf
Every tunable was a variable in a tracked script, so a personal value could
only live as an uncommittable edit. The repository copy and the installed
copy had already drifted over three sound paths pointing into a home
directory, which is the argument in one line.
Bash sources files natively: no format, no parser, and a syntax error is a
bash error at start rather than a silent fallback to a default."
```
---
### Task 2: A `config` verb
The daemon's effective settings are currently unobservable: `status` reports
phase and remaining seconds, not durations. That makes "did my config file
take effect?" unanswerable without reading the script, and it leaves the test
in Task 3 with nothing to assert against.
**Files:**
- Modify: `~/Programming/GIT/breaktimer/breaktimer.sh` (the `case` block, near line 228)
- [ ] **Step 1: Add the verb to the case statement**
Find:
```bash
status)
```
Insert immediately **before** it:
```bash
config)
printf 'MICRO_MIN=%s\nBREAK_MIN=%s\nLONG_MIN=%s\nLONG_EVERY=%s\n' \
"$MICRO_MIN" "$BREAK_MIN" "$LONG_MIN" "$LONG_EVERY"
printf 'WORK_START=%s\nWORK_STOP=%s\n' "$WORK_START" "$WORK_STOP"
printf 'URGENCY_MICRO=%s\nURGENCY_LONG=%s\n' "$URGENCY_MICRO" "$URGENCY_LONG"
printf 'SOUND_MICRO=%s\nSOUND_LONG=%s\nSOUND_BACK=%s\n' \
"$SOUND_MICRO" "$SOUND_LONG" "$SOUND_BACK"
printf 'CONFIG_FILE=%s\n' "$CONFIG_FILE"
[ -f "$CONFIG_FILE" ] && printf 'CONFIG_LOADED=yes\n' || printf 'CONFIG_LOADED=no\n'
;;
```
- [ ] **Step 2: Add it to the usage line**
Find:
```bash
echo "uso: $0 [start|stop|restart|pause|resume|toggle|status]"
```
Replace with:
```bash
echo "uso: $0 [start|stop|restart|pause|resume|toggle|status|config]"
```
- [ ] **Step 3: Verify both the default and the override**
Run:
```bash
cd ~/Programming/GIT/breaktimer
d=$(mktemp -d)
XDG_CONFIG_HOME="$d" bash breaktimer.sh config | grep -E 'MICRO_MIN|CONFIG_LOADED'
printf 'MICRO_MIN=45\n' > "$d/breaktimer.conf"
XDG_CONFIG_HOME="$d" bash breaktimer.sh config | grep -E 'MICRO_MIN|BREAK_MIN|CONFIG_LOADED'
rm -rf "$d"
```
Expected:
```
MICRO_MIN=30
CONFIG_LOADED=no
MICRO_MIN=45
BREAK_MIN=3
CONFIG_LOADED=yes
```
`BREAK_MIN=3` in the second block is the point: a config naming one variable
overrides that one and leaves the rest at their defaults.
- [ ] **Step 4: Commit**
```bash
cd ~/Programming/GIT/breaktimer
git add breaktimer.sh
git commit -m "feat: add a config verb printing effective settings
Durations were unobservable at runtime: status reports phase and seconds
left, not the configuration those came from, so 'did my config take effect'
could only be answered by reading the script.
It also gives the config test something to assert against without running a
thirty minute work block."
```
---
### Task 3: The runnable check
**Files:**
- Create: `~/Programming/GIT/breaktimer/test-breaktimer-config.sh`
- [ ] **Step 1: Write the test**
Create `test-breaktimer-config.sh` with exactly this content:
```bash
#!/bin/bash
#
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; version 2 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, see <https://www.gnu.org/licenses/>.
#
# The one runnable check for the config file. Points XDG_CONFIG_HOME and
# XDG_RUNTIME_DIR at temporary directories, so it never reads the user's
# config and never touches a live daemon. No daemon is started: every
# assertion goes through the `config` verb, which exits immediately.
#
# Usage: ./test-breaktimer-config.sh (exit 0 = all passed)
set -u
here="$(cd "$(dirname "$0")" && pwd)"
bt="$here/breaktimer.sh"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
export XDG_CONFIG_HOME="$tmp"
export XDG_RUNTIME_DIR="$tmp"
pass=0
fail=0
check() {
local label="$1" want="$2" got="$3"
if [[ "$want" == "$got" ]]; then
printf 'ok %s\n' "$label"
pass=$((pass + 1))
else
printf 'FAIL %s: want %q, got %q\n' "$label" "$want" "$got"
fail=$((fail + 1))
fi
}
# Read one KEY=value line out of the config verb.
cfg() { "$bt" config | grep "^$1=" | cut -d= -f2-; }
# No config file: the built-in defaults stand. This is the case every user
# without a config file exercises, so it is the regression that would hurt.
check "no config, MICRO_MIN default" "30" "$(cfg MICRO_MIN)"
check "no config, BREAK_MIN default" "3" "$(cfg BREAK_MIN)"
check "no config, reported as absent" "no" "$(cfg CONFIG_LOADED)"
# A config file overrides the variable it names.
printf 'MICRO_MIN=45\n' > "$tmp/breaktimer.conf"
check "config overrides MICRO_MIN" "45" "$(cfg MICRO_MIN)"
check "config reported as loaded" "yes" "$(cfg CONFIG_LOADED)"
# and leaves every variable it does not name alone. A config that silently
# reset the other values would be worse than no config at all.
check "unnamed value keeps its default" "3" "$(cfg BREAK_MIN)"
check "unnamed window keeps its default" "09:00" "$(cfg WORK_START)"
# Several values at once, including a string with a colon and a path.
cat > "$tmp/breaktimer.conf" <<'CONF'
MICRO_MIN=25
BREAK_MIN=5
WORK_START="08:30"
SOUND_MICRO="/tmp/nonexistent.opus"
CONF
check "multi: MICRO_MIN" "25" "$(cfg MICRO_MIN)"
check "multi: BREAK_MIN" "5" "$(cfg BREAK_MIN)"
check "multi: quoted time" "08:30" "$(cfg WORK_START)"
check "multi: path" "/tmp/nonexistent.opus" "$(cfg SOUND_MICRO)"
# An empty config file is not an error: it is a file the user has not filled
# in yet, and every default must survive it.
: > "$tmp/breaktimer.conf"
check "empty config keeps defaults" "30" "$(cfg MICRO_MIN)"
check "empty config still reports loaded" "yes" "$(cfg CONFIG_LOADED)"
printf '\n%d passed, %d failed\n' "$pass" "$fail"
[[ "$fail" -eq 0 ]]
```
- [ ] **Step 2: Make it executable and run it**
Run:
```bash
cd ~/Programming/GIT/breaktimer
chmod +x test-breaktimer-config.sh
./test-breaktimer-config.sh
```
Expected: thirteen `ok` lines, then:
```
13 passed, 0 failed
```
and exit 0. Confirm with `echo $?` if the shell does not show it.
- [ ] **Step 3: Verify the test can actually fail**
A test that cannot fail proves nothing. Temporarily break the source line:
```bash
cd ~/Programming/GIT/breaktimer
sed -i 's|^\[ -f "\$CONFIG_FILE" \] && \. "\$CONFIG_FILE"|: # disabled|' breaktimer.sh
./test-breaktimer-config.sh; echo "exit=$?"
git checkout breaktimer.sh
./test-breaktimer-config.sh | tail -1
```
Expected: the middle run reports failures including
`FAIL config overrides MICRO_MIN: want "45", got "30"` and `exit=1`. After the
`git checkout` restores the line, the last run prints `13 passed, 0 failed`
again.
- [ ] **Step 4: Commit**
```bash
cd ~/Programming/GIT/breaktimer
git add test-breaktimer-config.sh
git commit -m "test: cover the config file path
Two cases carry the weight: an override takes effect, and a missing config
still yields the built-in defaults. The second is what every user without a
config file runs, so it is the regression worth pinning.
No daemon is started. Every assertion goes through the config verb, which
exits immediately, so the check is instant and cannot leave a stray process."
```
---
### Task 4: Document the config file
**Files:**
- Modify: `~/Programming/GIT/breaktimer/README.md` (add a Configuration section; amend the Sounds section)
- [ ] **Step 1: Read the sections to place this between**
Run:
```bash
cd ~/Programming/GIT/breaktimer && grep -n '^#\{1,3\} ' README.md
```
Expected: a list of headings including `## Install`, `### Sounds` and
`## Usage`. Place the new `## Configuration` section immediately before
`## Usage`.
- [ ] **Step 2: Add the Configuration section**
Insert before the `## Usage` heading:
```markdown
## Configuration
Defaults live at the top of `breaktimer.sh`. To change them without editing a
tracked file, write `~/.config/breaktimer.conf` (or
`$XDG_CONFIG_HOME/breaktimer.conf`). It is sourced as shell, so it is a list of
assignments, and it need only name what it changes:
```bash
MICRO_MIN=25
BREAK_MIN=5
WORK_START="08:30"
SOUND_MICRO="$HOME/Music/notify/pausetta.opus"
```
| variable | default | meaning |
|---|---|---|
| `MICRO_MIN` | 30 | minutes of work between breaks |
| `BREAK_MIN` | 3 | length of a micro-pause |
| `LONG_MIN` | 10 | length of a long pause |
| `LONG_EVERY` | 4 | a long pause instead of every Nth micro-pause |
| `WORK_START` | 09:00 | countdown freezes before this |
| `WORK_STOP` | 18:30 | countdown freezes after this |
| `URGENCY_MICRO` | normal | notify-send urgency for a micro-pause |
| `URGENCY_LONG` | critical | notify-send urgency for a long pause |
| `SOUND_MICRO` | unset | sound for a micro-pause, falls back to `SYS_SOUND_MICRO` |
| `SOUND_LONG` | unset | sound for a long pause, falls back to `SYS_SOUND_LONG` |
| `SOUND_BACK` | unset | sound for going back to work, falls back to `SYS_SOUND_BACK` |
Check what is in effect:
```bash
breaktimer.sh config
```
The file is read when the daemon starts, so a change takes effect on
`breaktimer.sh restart`. A syntax error in it is a bash error at start,
reported on stderr.
The check for all of this is `./test-breaktimer-config.sh`.
```
- [ ] **Step 3: Point the Sounds section at the config file**
In the `### Sounds` section, find the sentence telling the reader to edit the
script:
```
it there, or edit the `SOUND_DIR` / `SYS_SOUND_*` variables at the top of
`breaktimer.sh` to point at any `.oga`/`.wav` you like
```
Replace with:
```
it there, or set `SOUND_DIR` / `SYS_SOUND_*` in `~/.config/breaktimer.conf`
(see Configuration below) to point at any `.oga`/`.wav` you like
```
- [ ] **Step 4: Add `config` to the usage block**
In the `## Usage` section, find the line listing the verbs and add `config` to
it, so it reads:
```
breaktimer.sh start|stop|restart|pause|resume|toggle|status|config
```
Run `grep -n 'toggle' README.md` first to find every place that list appears,
and update each one.
- [ ] **Step 5: Verify the documented defaults are true**
The table above is a claim about the script. Check it rather than trusting it:
```bash
cd ~/Programming/GIT/breaktimer
d=$(mktemp -d); XDG_CONFIG_HOME="$d" bash breaktimer.sh config; rm -rf "$d"
```
Expected: `MICRO_MIN=30`, `BREAK_MIN=3`, `LONG_MIN=10`, `LONG_EVERY=4`,
`WORK_START=09:00`, `WORK_STOP=18:30`, `URGENCY_MICRO=normal`,
`URGENCY_LONG=critical`, and three empty `SOUND_*` values. Every one must match
the table; fix the table if any differs.
- [ ] **Step 6: Commit**
```bash
cd ~/Programming/GIT/breaktimer
git add README.md
git commit -m "docs: document breaktimer.conf
The Sounds section told the reader to edit the script, which is what put a
home directory in a tracked file in the first place."
```
---
### Task 5: Read the daemon's files in the Status singleton
**Files:**
- Modify: `~/Programming/GIT/quickshell/shared/Status.qml`
Three read-only views over files the daemon already publishes. They follow the
existing `ModeFile` component's shape: watched, errors silenced, a missing file
treated as the off state. They differ in parsing a word or an integer rather
than `0` or `1`, and in having no `write` function at all, because the daemon
owns these files and two writers would race its loop.
- [ ] **Step 1: Add the three properties**
After the `nolock` property declaration (near line 37), before `activeCount`,
insert:
```qml
// Breaktimer, read only. The daemon publishes these three files and owns
// them; the shell calls verbs and never writes them, because the daemon
// loop rewrites state and phase on every transition.
//
// A stopped daemon is read from the state file rather than probed: both
// paths that end it, stop_daemon and the cleanup trap, write "stopped"
// there. A daemon lost to SIGKILL leaves a stale "running" and the drawer
// shows a frozen countdown, which is a visible wrong answer the start
// button resolves. That is cheaper than a liveness probe per repaint.
readonly property string btState: btStateFile.value
readonly property string btPhase: btPhaseFile.value
readonly property int btRemain: btRemainFile.value
readonly property bool btRunning: root.btState === "running"
|| root.btState === "paused"
readonly property bool btPaused: root.btState === "paused"
```
- [ ] **Step 2: Add the two file components**
After the `ModeFile` component definition (after its closing brace, near line
176), before the three `ModeFile` instances, insert:
```qml
// A daemon file holding a word. Same watch and same missing-file rule as
// ModeFile, without a write path: this side only reads.
component WordFile: FileView {
id: wf
property string value: "stopped"
function reparse() {
const t = wf.text().trim();
if (t !== wf.value) wf.value = t;
}
watchChanges: true
printErrors: false
onFileChanged: wf.reload()
onLoaded: wf.reparse()
// No file means no daemon, which is the stopped state, not an error.
onLoadFailed: wf.value = "stopped"
}
// The remaining seconds. Validated as an integer rather than trusted:
// the file is written every tick and a read can catch it mid-write, and
// an empty string coerces to 0 silently while NaN would propagate into
// the countdown as "NaN:aN".
component SecondsFile: FileView {
id: sf
property int value: 0
function reparse() {
const n = parseInt(sf.text().trim(), 10);
const v = (isNaN(n) || n < 0) ? 0 : n;
if (v !== sf.value) sf.value = v;
}
watchChanges: true
printErrors: false
onFileChanged: sf.reload()
onLoaded: sf.reparse()
onLoadFailed: sf.value = 0
}
```
- [ ] **Step 3: Add the three instances**
After the three existing `ModeFile` instances at the end of the file, insert:
```qml
WordFile { id: btStateFile; path: root.dir + "/breaktimer.state" }
WordFile { id: btPhaseFile; path: root.dir + "/breaktimer.phase" }
SecondsFile { id: btRemainFile; path: root.dir + "/breaktimer.remain" }
```
- [ ] **Step 4: Add a countdown formatter**
After the `runBreaktimer` function (near line 122), insert:
```qml
// Seconds as m:ss. The daemon rewrites the remaining seconds every five
// seconds, its tick, so this counts down in five second steps and does
// not interpolate: a local one second timer would be a second clock
// drifting against the first, correcting itself with a visible jump, and
// it would keep ticking while the daemon is frozen outside the work
// window or paused.
function btCountdown() {
const s = Math.max(0, root.btRemain);
return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
}
```
- [ ] **Step 5: Verify the shell loads clean**
The singleton is `alwaysActive`, so a syntax error appears at load with no
drawer interaction. Restart the shell and read the log, rather than checking
`pgrep` afterwards, which reports DEAD for a detached process regardless:
```bash
pkill -x qs; sleep 1; pgrep -cx qs
cd ~/Programming/GIT/quickshell && timeout 12 qs -p desktop > /tmp/qs-t5.log 2>&1
grep -E 'Configuration Loaded|rror|Unable|Status.qml' /tmp/qs-t5.log
```
Expected: `0` from the `pgrep` count, then `Configuration Loaded` with no
`ReferenceError`, no `Unable to assign`, and no line naming `Status.qml`.
Redirect rather than pipe. `qs` buffers its output, so `qs ... 2>&1 | head`
loses everything when `timeout` kills it and reads as a silent success. The
`timeout` itself is deliberate: it keeps the process owned by this command, and
a detached `qs` checked with `pgrep` in a later call always reports dead.
- [ ] **Step 6: Verify the properties actually track the daemon**
Reading a file is the whole task, so prove it reads. With the shell running in
one terminal, drive the daemon from another and watch the values change. The
quickest proof is the tile, which Task 7 has not touched yet, so use the
daemon's own output as the reference:
```bash
breaktimer.sh start; sleep 6; breaktimer.sh status
cat "$XDG_RUNTIME_DIR"/breaktimer.{state,phase,remain}
```
Expected: `status` reports `attivo`, state `running`, phase `working`, and a
remaining count under 1800 that has decreased by about 5 since start. The three
files hold exactly those values, one per line. Leave the daemon running for the
next tasks.
- [ ] **Step 7: Commit**
```bash
cd ~/Programming/GIT/quickshell
git add shared/Status.qml
git commit -m "feat(status): read breaktimer phase, state and countdown
The daemon already publishes these three files under XDG_RUNTIME_DIR and
waybar already consumes them, so a second consumer costs three FileViews and
invents no interface.
Read only, deliberately. The daemon loop rewrites state and phase on every
transition, so a second writer would race it, which is why presentation mode
has always called a verb rather than writing the file.
The countdown does not interpolate between the daemon's five second writes: a
local clock would drift against it and keep ticking while the daemon is frozen
outside the work window."
```
---
### Task 6: The row
**Files:**
- Create: `~/Programming/GIT/quickshell/desktop/modules/status/BreakRow.qml`
Modelled on `SnoozeRow.qml`, which is the existing row with a switch plus a
second control in a `Row`.
- [ ] **Step 1: Create the file**
Create `desktop/modules/status/BreakRow.qml` with exactly this content:
```qml
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 2 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
import QtQuick
import "../.."
// Breaktimer: the daemon's phase and countdown, a pause switch and a
// start/stop button. Every control is a verb; nothing here writes the
// daemon's files.
Item {
id: row
implicitHeight: Math.max(texts.implicitHeight, controls.implicitHeight) + 16
readonly property string phaseLabel: {
if (!Status.btRunning) return "Stopped";
if (Status.btPaused) return "Paused";
if (Status.btPhase === "breaking") return "Micro-pause";
if (Status.btPhase === "longbreak") return "Long pause";
return "Working";
}
// A paused countdown is frozen, and a frozen number reads as a bug, so
// the paused state says what it is instead of showing one.
readonly property string detail: {
if (!Status.btRunning) return "Break reminders are not running.";
if (Status.btPaused) return "Countdown frozen until resumed.";
if (Status.btPhase === "breaking" || Status.btPhase === "longbreak")
return "Back to work in " + Status.btCountdown() + ".";
return "Next break in " + Status.btCountdown() + ".";
}
Column {
id: texts
anchors {
left: parent.left
right: controls.left
rightMargin: 12
verticalCenter: parent.verticalCenter
}
spacing: 2
Text {
text: "Breaktimer · " + row.phaseLabel
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
color: Theme.text
}
Text {
width: parent.width
wrapMode: Text.WordWrap
text: row.detail
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
color: Theme.subtext
}
}
Row {
id: controls
anchors { right: parent.right; verticalCenter: parent.verticalCenter }
spacing: 8
Button {
text: Status.btRunning ? "Stop" : "Start"
danger: Status.btRunning
onClicked: Status.runBreaktimer(Status.btRunning ? "stop" : "start")
}
Switch {
id: sw
enabled: Status.btRunning
checked: Status.btRunning && !Status.btPaused
onToggled: Status.runBreaktimer(Status.btPaused ? "resume" : "pause")
}
}
// The shared Switch writes `checked` on click and so drops the binding
// above. Resync on any state change, which also covers a change made from
// the waybar module's own bindings or from a terminal.
Connections {
target: Status
function onBtStateChanged() {
sw.checked = Status.btRunning && !Status.btPaused;
}
}
}
```
- [ ] **Step 2: Mount it in the page**
In `desktop/modules/status/StatusPage.qml`, after the closing brace of the
`SnoozeRow` block (near line 62), before the final closing brace of the
`Column`, insert:
```qml
Rectangle {
width: page.width
height: 1
color: Qt.alpha(Theme.text, 0.08)
}
BreakRow {
width: page.width
}
```
- [ ] **Step 3: Force the qmldir rescan**
A new file in a directory is invisible to a running shell until a file that
imports that directory reloads. `StatusPage.qml` was just edited, which is in
the same directory, but the import that resolves `BreakRow` is the directory
import, so restart rather than relying on it:
```bash
pkill -x qs; sleep 1; pgrep -cx qs
```
Expected: `0`. Note the process is `qs`, never `quickshell`, and `-x` never
`-f`: `pkill -f` matches this shell's own command line and kills the caller.
- [ ] **Step 4: Verify the row loads and reflects the daemon**
```bash
breaktimer.sh start
cd ~/Programming/GIT/quickshell && timeout 40 qs -p desktop > /tmp/qs-t6.log 2>&1
grep -E 'rror|Unable|BreakRow' /tmp/qs-t6.log
```
While it runs, open the drawer and the Status page; the row is only
instantiated when the page is open, so a log checked without opening it is a
clean result that proves nothing. Expected in the log: no
`ReferenceError: BreakRow is not defined`, no `Unable to assign`, no line
naming `BreakRow.qml`. Expected on screen: a Breaktimer row reading
`Breaktimer · Working` with `Next break in <m:ss>.`, the switch on, the button
reading `Stop`.
If the row is missing and the log says `BreakRow is not defined`, the qmldir
rescan did not happen: the shell was not actually restarted, or an older `qs`
is still running. Check with `pgrep -cx qs` and expect exactly one.
- [ ] **Step 5: Commit**
```bash
cd ~/Programming/GIT/quickshell
git add desktop/modules/status/BreakRow.qml desktop/modules/status/StatusPage.qml
git commit -m "feat(status): a breaktimer row in the status page
Into the status module rather than a module of its own: break state is one
more thing the desktop is doing, which is what that page already is, and
Status.qml was already where breaktimer.sh is called from.
The paused state shows no number. The countdown is frozen while paused and a
frozen number reads as a bug, so the row says what it is instead."
```
---
### Task 7: The tile line
**Files:**
- Modify: `~/Programming/GIT/quickshell/desktop/modules/status/StatusTile.qml`
- [ ] **Step 1: Extend the priority chain**
Replace the `text` binding:
```qml
text: Status.presentation ? "Presenting"
: Status.dnd ? "Do not disturb"
: Status.nolock ? "No lock"
: "All clear"
```
with:
```qml
// Breaktimer sits below every mode, so an active mode still owns the line
// and breaktimer replaces only the idle "All clear". A stopped daemon
// falls through to it, which is true: nothing is being tracked.
text: Status.presentation ? "Presenting"
: Status.dnd ? "Do not disturb"
: Status.nolock ? "No lock"
: !Status.btRunning ? "All clear"
: Status.btPaused ? "Paused"
: (Status.btPhase === "breaking" || Status.btPhase === "longbreak")
? "Back in " + Status.btCountdown()
: "Break in " + Status.btCountdown()
```
- [ ] **Step 2: Keep the accent honest**
The tile renders in its accent while `Status.activeCount > 0`, and a running
breaktimer is not a mode. Leave `activeCount` alone: breaktimer is state the
tile reports, not a mode the user switched on, and colouring the tile for it
would make an ordinary working day look like a mode is stuck.
No edit in this step. It exists so the next reader does not "fix" it.
- [ ] **Step 3: Verify each branch**
The tile is the one place all five branches are visible, so walk them:
```bash
breaktimer.sh start; sleep 1; breaktimer.sh status
```
Expected on the tile: `Break in <m:ss>`, counting down in five second steps.
```bash
breaktimer.sh pause
```
Expected: `Paused`, no number.
```bash
breaktimer.sh resume; statusctl dnd set 1
```
Expected: `Do not disturb`. A mode outranks breaktimer even though the daemon
is running, which is the priority claim.
```bash
statusctl dnd set 0
```
Expected: back to `Break in <m:ss>`.
```bash
breaktimer.sh stop
```
Expected: `All clear`.
- [ ] **Step 4: Commit**
```bash
cd ~/Programming/GIT/quickshell
git add desktop/modules/status/StatusTile.qml
git commit -m "feat(status): show the breaktimer countdown on the tile
Below every mode in the priority chain, so an active mode still owns the line
and breaktimer replaces only the idle 'All clear'.
It does not count toward activeCount. A running breaktimer is not a mode the
user switched on, and accenting the tile for an ordinary working day would
read as a mode stuck on."
```
---
### Task 8: Document it in the module README
**Files:**
- Modify: `~/Programming/GIT/quickshell/desktop/modules/status/README.md`
- [ ] **Step 1: Replace the existing breaktimer paragraph**
That paragraph is the last thing in the Effects section, at lines 56-58.
Confirm before editing:
```bash
cd ~/Programming/GIT/quickshell && sed -n '56,58p' desktop/modules/status/README.md
```
Expected:
```
breaktimer owns `$XDG_RUNTIME_DIR/breaktimer.state`. This module calls
`breaktimer.sh pause|resume` and never writes that file: its daemon loop
rewrites it on every phase change, and two writers would race.
```
Replace those three lines with a pointer, so the Effects section still ends on
an effect rather than trailing off into detail:
```markdown
breaktimer is paused and resumed by verb, never by writing its state file. See
Breaktimer below, which is also the read direction.
```
Then add the new section. It goes **after** the Effects section and before
`## Waybar`, as its own top-level heading:
```markdown
## Breaktimer
The traffic runs both ways, and only one way writes.
**Reading.** The daemon publishes three files the singleton watches:
$XDG_RUNTIME_DIR/breaktimer.state running | paused | stopped
$XDG_RUNTIME_DIR/breaktimer.phase working | breaking | longbreak
$XDG_RUNTIME_DIR/breaktimer.remain seconds left in the phase
exposed as `Status.btState`, `btPhase` and `btRemain`, with `btRunning` and
`btPaused` derived from the first. `waybar-breaktimer.sh` reads the same three
files and the two consumers do not know about each other.
**Writing: never.** The daemon owns those files and rewrites state and phase on
every transition, so a second writer would race its loop. Every control calls a
verb through `runBreaktimer()`, which is why presentation mode has always
called `pause` rather than writing `breaktimer.state`.
**A stopped daemon** is read from the state file, not probed. Both paths that
end the daemon write `stopped` there: `stop_daemon`, and the `cleanup` trap on
`TERM`. A daemon lost to `KILL` leaves a stale `running` and the drawer shows a
frozen countdown, a visible wrong answer the Start button resolves, which is
cheaper than a liveness probe on every repaint. QML cannot send a signal, so
the `kill -0` check the waybar module uses is not available here anyway.
**The countdown counts in five second steps**, because that is the daemon's
tick and the shell does not interpolate between its writes. A local one second
timer would be a second clock drifting against the first, correcting itself
with a visible jump every five seconds, and it would keep counting while the
daemon is frozen outside the work window or paused.
**The tile** shows breaktimer below every mode, so an active mode still owns
the line and breaktimer replaces only the idle `All clear`. It does not count
toward `activeCount`: a running daemon is not a mode the user switched on.
The daemon's own configuration lives in `~/.config/breaktimer.conf` and is not
edited from here; `breaktimer.sh config` prints what is in effect.
```
- [ ] **Step 2: Amend the presentation effect description**
In the Effects section, the presentation paragraph says it "pauses breaktimer".
That is still true and needs no change. Confirm it reads correctly next to the
new section:
```bash
cd ~/Programming/GIT/quickshell && sed -n '43,46p' desktop/modules/status/README.md
```
Expected: the paragraph beginning ``` `presentation` sets `dnd` and `nolock` ```,
naming the idle inhibitor and pausing breaktimer, unchanged.
- [ ] **Step 3: Commit**
```bash
cd ~/Programming/GIT/quickshell
git add desktop/modules/status/README.md
git commit -m "docs(status): document the breaktimer read direction
The one rule worth writing down is which side writes: the daemon owns its
files and the shell only calls verbs."
```
---
### Task 9: End-to-end verification
No new code. This is the check the QML half does not otherwise have, and it
is not optional: every control is observable from the terminal, so there is no
excuse for "it looked right".
**Files:** none
- [ ] **Step 1: Start clean**
```bash
breaktimer.sh stop
pkill -x qs; sleep 1; pgrep -cx qs
```
Expected: `0`. If it is not `0`, an instance survived; check for a second
one before continuing, because a stacked shell is how this repo once
accumulated 47 processes.
- [ ] **Step 2: Start the shell so the harness owns it**
```bash
cd ~/Programming/GIT/quickshell && timeout 300 qs -p desktop 2>&1 | tee /tmp/qs-breaktimer.log
```
Leave this running for the rest of the task and work from a second terminal.
- [ ] **Step 3: Walk the states from the terminal**
Run each, and check the drawer's Status page and the tile after each:
```bash
breaktimer.sh start && sleep 6 && breaktimer.sh status
```
Expected from `status`: `attivo`, state `running`, phase `working`, remaining
under 1800. Expected on screen: row reads `Breaktimer · Working`, `Next break
in <m:ss>`, switch on, button `Stop`; tile reads `Break in <m:ss>`.
```bash
breaktimer.sh pause && breaktimer.sh status
```
Expected: state `paused`; row `Breaktimer · Paused`, `Countdown frozen until
resumed.`, switch off, button still `Stop`; tile `Paused`.
```bash
breaktimer.sh resume && breaktimer.sh status
```
Expected: state `running`; the row and tile return to the working text and the
switch goes back on **without touching the drawer**. This is the resync path,
and it is the one most likely to be broken.
```bash
breaktimer.sh stop && breaktimer.sh status
```
Expected: `non in esecuzione`; row `Breaktimer · Stopped`, `Break reminders are
not running.`, switch dimmed and unresponsive, button `Start`; tile `All
clear`.
- [ ] **Step 4: Drive it from the drawer and confirm from the terminal**
Click `Start` in the row, then in the second terminal:
```bash
breaktimer.sh status
```
Expected: `attivo`, state `running`. Then click the switch off and run it
again: state `paused`. Click it on: state `running`. Click `Stop`: `non in
esecuzione`.
A control that appears to work but changes nothing is exactly what this step
catches.
- [ ] **Step 5: Confirm presentation mode still pauses it**
This path predates the change and must not have regressed:
```bash
breaktimer.sh start && statusctl presentation set 1 && sleep 1 && breaktimer.sh status
```
Expected: state `paused`, and the row shows `Paused` with the switch off.
```bash
statusctl presentation set 0 && sleep 1 && breaktimer.sh status
```
Expected: state `running`, row back to working.
- [ ] **Step 6: Confirm the log is clean**
```bash
grep -iE 'error|warning|unable|undefined|NaN' /tmp/qs-breaktimer.log
```
Expected: no output. A `TypeError` here would be the signature of a property
arriving undefined, which renders as a plausible empty row with only a log
line to show for it.
- [ ] **Step 7: Confirm the config half from the same session**
```bash
cd ~/Programming/GIT/breaktimer && ./test-breaktimer-config.sh | tail -1
breaktimer.sh config | grep -E 'MICRO_MIN|CONFIG_LOADED'
```
Expected: `13 passed, 0 failed`, then the live values. If `CONFIG_LOADED=no`,
that is correct unless a `~/.config/breaktimer.conf` has been written.
- [ ] **Step 8: Stop the shell and restore the session**
```bash
pkill -x qs; sleep 1; pgrep -cx qs
breaktimer.sh start
```
Expected: `0`, then the daemon running as it normally does. The shell restarts
from the Hyprland autostart at next login; start it by hand if the session
needs it back now.
- [ ] **Step 9: Move the user's sound paths into a config file**
The drift that motivated the config file is still live: the installed
`~/bin/breaktimer.sh` differs from the repository copy in three sound paths.
Resolve it.
```bash
diff ~/bin/breaktimer.sh ~/Programming/GIT/breaktimer/breaktimer.sh
```
Expected: the three `SOUND_*` lines, and nothing else once the repository
changes are installed.
Write the config file, then install the repository copy over the edited one:
```bash
cat > ~/.config/breaktimer.conf <<'CONF'
SOUND_MICRO="$HOME/Music/notify/pausetta.opus"
SOUND_LONG="$HOME/Music/notify/pausa-lunga.opus"
SOUND_BACK="$HOME/Music/notify/riprendiamo.opus"
CONF
cp ~/Programming/GIT/breaktimer/breaktimer.sh ~/bin/breaktimer.sh
chmod +x ~/bin/breaktimer.sh
diff ~/bin/breaktimer.sh ~/Programming/GIT/breaktimer/breaktimer.sh && echo IDENTICAL
breaktimer.sh config | grep SOUND_
```
Expected: `IDENTICAL`, then the three paths resolved to the home directory,
proving the config file supplies what the script edit used to. Confirm the
files exist:
```bash
ls -l ~/Music/notify/pausetta.opus ~/Music/notify/pausa-lunga.opus ~/Music/notify/riprendiamo.opus
```
Expected: three files. A missing one is silent by design, so this is the only
place it would be noticed.
```bash
breaktimer.sh restart && breaktimer.sh status
```
Expected: `attivo`. The restart is what loads the new config.
---
## Self-Review
**Spec coverage.** Every section of the spec maps to a task:
| spec section | task |
|---|---|
| Part one: a config file, the change | 1 |
| Scope of a change (restart to apply) | 1 step 2 comment, 4 step 2 |
| What moves into the file | 9 step 9 |
| Documentation | 4 |
| Part two: reading | 5 |
| Detecting a stopped daemon | 5 step 1 comment, 8 |
| Countdown granularity | 5 step 4, 8 |
| The row | 6 |
| The tile | 7 |
| Verbs (`runBreaktimer` gains callers) | 6, 7 |
| Checks: breaktimer repo | 3 |
| Checks: quickshell, visual | 6 step 4, 7 step 3, 9 |
The `config` verb is not in the spec. It was added while planning, because the
spec's test requirement ("a config file overriding a value takes effect") has
nothing to assert against otherwise, short of running a work block. It is one
`printf` block and it makes the daemon's settings observable, which the spec
notes is currently impossible. Task 2 carries the reasoning.
**Type consistency.** The names used across tasks agree: `btState`, `btPhase`,
`btRemain`, `btRunning`, `btPaused`, `btCountdown()` are defined in Task 5 and
used under those names in Tasks 6, 7 and 8. `runBreaktimer(verb)` already
exists in `shared/Status.qml` and is called, not redefined. `WordFile` and
`SecondsFile` are defined and instantiated in Task 5 only. `Switch.enabled` and
`Button.danger` exist in `desktop/Switch.qml` and `desktop/Button.qml`.
**Placeholders.** None. Every code step carries its code and every verification
step its exact command and expected output.
**Verified while planning.** Three things the plan depends on were probed
rather than assumed, because each has a precedent for biting in this repo:
- `String.prototype.padStart` exists in this QML JS engine. Task 5's formatter
uses it, and `String.matchAll` is absent here, so the family is not safe by
default. A throwaway `ShellRoot` logged `07`.
- `Connections { target: Status; function onBtStateChanged() }` fires on every
assignment and reads the derived properties correctly at that moment,
including the resume transition Task 9 singles out. Probed against a
stand-in singleton with the same property shape.
- The config source line overrides a named variable and leaves every unnamed
one at its default, which is the behaviour Task 3 asserts.
One thing was **not** verified and is a real risk: whether `qs` output is
visible when piped. It is buffered, so `qs -p dir 2>&1 | head` can lose
everything when `timeout` kills it. Every verification step here redirects to
a file and reads it afterwards, which is why they are written that way rather
than as pipes.
|