aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-09-18-breaktimer-status.md1259
1 files changed, 1259 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-18-breaktimer-status.md b/docs/superpowers/plans/2026-09-18-breaktimer-status.md
new file mode 100644
index 0000000..8e203b9
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-18-breaktimer-status.md
@@ -0,0 +1,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.