# 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. # # 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 . # # 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. // // 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 .`, 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 `, 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 `. ```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 `, switch on, button `Stop`; tile reads `Break in `. ```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.