diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-18 16:56:34 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-18 16:56:34 +0200 |
| commit | 411d849bdbe41a6c9ae94f4ebe6dc3d798757ee7 (patch) | |
| tree | 416d431b3a45df298fc6d9d18fb2faf6f6eb7907 /docs | |
| parent | cc132f5d02f1bbb544bd22ce4120df70be869069 (diff) | |
| download | quickshell-411d849bdbe41a6c9ae94f4ebe6dc3d798757ee7.tar.gz quickshell-411d849bdbe41a6c9ae94f4ebe6dc3d798757ee7.zip | |
docs(status): implementation plan for breaktimer control
Nine tasks over two repos. The daemon half gains a sourced config file and a
config verb that prints the effective settings, which is what makes the
config testable without running a thirty minute work block. The shell half
reads the daemon's three runtime files, adds a row and a tile line.
Three assumptions were probed rather than trusted while writing it, each
with a precedent for biting here: padStart exists in this QML engine
(matchAll does not), a Connections resync fires correctly on a singleton
property, and the config source line leaves unnamed variables alone.
The qs verification steps redirect rather than pipe. Piped output is lost
when timeout kills the process, which reads as a silent success, and that
failure happened twice while probing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-09-18-breaktimer-status.md | 1259 |
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. |
