diff options
Diffstat (limited to 'docs/superpowers/plans')
| -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. |
