diff options
| -rw-r--r-- | CLAUDE.md | 45 | ||||
| -rw-r--r-- | README.md | 134 | ||||
| -rw-r--r-- | breaktimer.css | 27 | ||||
| -rwxr-xr-x | breaktimer.sh | 234 | ||||
| -rw-r--r-- | waybar-breaktimer.config.jsonc | 43 | ||||
| -rwxr-xr-x | waybar-breaktimer.sh | 47 |
6 files changed, 530 insertions, 0 deletions
diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f47ab25 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,45 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`breaktimer` is a Bash break-reminder daemon for a Linux/Wayland desktop (dunst + pipewire + Waybar). No build, no package manager, no tests — it's a single shell script plus Waybar integration files. Comments and notification strings are in Italian; keep that convention when editing. + +## Running + +```bash +./breaktimer.sh start|stop|restart|pause|resume|toggle|status +./breaktimer.sh run # internal: the daemon loop, not called directly +``` + +Dependencies: `dunst` (notify-send), `pipewire` (pw-play, paplay fallback), coreutils. + +## Architecture + +State machine: `working -> (notify) -> breaking | longbreak -> working`. Every `LONG_EVERY` (4th) work block triggers a long break instead of a micro-break. + +**Time model is the core invariant** (breaktimer.sh:8-10): the daemon persists REMAINING seconds of the current phase, not absolute timestamps. Each `TICK` (5s) it decrements REMAINING *only while working/counting*. Consequences: +- Manual pause (`paused` state) freezes the countdown — tick `continue`s without decrementing. +- Outside the `WORK_START`..`WORK_STOP` window, the `working` phase freezes (breaks still count down). +- Breaks never erode work time. + +Any change to the loop must preserve "freeze = don't decrement REMAINING" so the Waybar countdown stays coherent. + +### Daemon mechanics +- `start_daemon` forks via `setsid "$0" run`. It does **not** write the PID itself — `$!` after a `setsid &` is not the loop's real PID. Instead the loop writes its own `$$` to the PID file, and `start_daemon` polls `is_running` until it appears (then reports success/failure). +- **Singleton guard**: `run_loop` refuses to start (exit 1) if the PID file names a *live* process other than itself (`kill -0`). This stops a stray `breaktimer.sh run` from clobbering a running daemon's shared state files. +- All state lives in files under `$XDG_RUNTIME_DIR` (falls back to `/tmp`): `breaktimer.pid`, `.state` (running|paused), `.phase` (working|breaking|longbreak|stopped), `.remain` (seconds). Subcommands like `pause`/`toggle` work by writing these files; the running loop reads them each tick. This file-based IPC is how the CLI talks to the daemon. +- **TERM/INT `cleanup` trap deliberately does NOT delete the PID file.** Bash defers a trap until the current `sleep` returns (up to `TICK`=5s), so on `restart` the *old* daemon's trap fires ~5s after the *new* one already wrote its PID — deleting it there would orphan the new daemon. A stale PID file is harmless because `is_running` uses `kill -0`; the next `start` overwrites it. The trap only writes `stopped` and removes `.remain`. + +### Config +Tunables are top-of-file variables in breaktimer.sh: timing (`MICRO_MIN`, `BREAK_MIN`, `LONG_MIN`, `LONG_EVERY`), work window (`WORK_START`, `WORK_STOP`), urgency levels, and sounds. + +**Sounds**: three events — micro-pause, long-pause, back-to-work — each with a `SOUND_*` user override and a `SYS_SOUND_*` default. Defaults point at `$SOUND_DIR` (`~/.local/share/sounds/modern-minimal-ui-sounds/stereo`): `message-new-instant.oga` / `alarm-clock-elapsed.oga` / `service-login.oga`. `sound_for_{micro,long,back}` use the override if set and the file exists, else the default. Playback via `pw-play`, falling back to `paplay`. Empty/missing file = silent (no error). + +## Waybar integration + +- `waybar-breaktimer.config.jsonc` — the `custom/breaktimer` module: polls every 5s, left-click `toggle`, right-click `restart`, expects `return-type: json`. +- `breaktimer.css` — phase-colored styling for `#custom-breaktimer.{working,breaking,longbreak,paused,stopped}` (Catppuccin colors). + +- `waybar-breaktimer.sh` — JSON status emitter the Waybar `exec` calls. Reads the `.phase`/`.remain`/`.state` files (no time recomputation — the daemon already froze `.remain`), maps phase -> CSS `class`, formats `.remain` as `m:ss`, and prints `{text, class, tooltip}`. Nerd Font glyphs in `text`. Returns the `stopped` class when the daemon isn't running. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe59bfd --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# breaktimer + +Break reminder for Linux/Wayland. A small Bash daemon that nudges you to take +micro-pauses while working at the PC, with desktop notifications, sounds, and a +[Waybar](https://github.com/Alexays/Waybar) module showing a live countdown. + +Notifications and tooltips are in Italian. + +## How it works + +State machine: **working → micro-pause → working**, and every 4th block a +**long pause** instead. The countdown freezes when you pause manually and +outside your work-hours window, so breaks never eat into work time and the +Waybar number stays honest. + +| Phase | Default | Notification | +|-----------|---------|--------------------------| +| working | 30 min | — | +| breaking | 3 min | "🚶 Micro-pausa" | +| longbreak | 10 min | "⏸️ Pausa lunga" (every 4th) | + +## Dependencies + +- `dunst` (or any `notify-send` provider) +- `pipewire` — `pw-play`, falls back to `paplay` (PulseAudio) +- coreutils, Bash +- [Waybar](https://github.com/Alexays/Waybar) (optional, for the bar module) +- A Nerd Font for the Waybar glyphs (optional) + +## Install + +The Waybar config calls the scripts from `~/bin`. Put both there and make them +executable: + +```bash +mkdir -p ~/bin +cp breaktimer.sh waybar-breaktimer.sh ~/bin/ +chmod +x ~/bin/breaktimer.sh ~/bin/waybar-breaktimer.sh +``` + +Make sure `~/bin` is on your `PATH` (or call the scripts by full path). + +### Sounds + +Defaults use the [Modern Minimal UI](https://github.com/cadecomposer/modern-minimal-ui-sounds) sound set at: + +``` +~/.local/share/sounds/modern-minimal-ui-sounds/stereo/ +``` + +Three events map to `message-new-instant.oga` (micro), `alarm-clock-elapsed.oga` +(long), `service-login.oga` (back to work). Don't have that set? Either install +it there, or edit the `SOUND_DIR` / `SYS_SOUND_*` variables at the top of +`breaktimer.sh` to point at any `.oga`/`.wav` you like (e.g. the freedesktop +sounds in `/usr/share/sounds/freedesktop/stereo/`). A missing file is simply +silent — no error. + +## Usage + +```bash +breaktimer.sh start # start the daemon in the background +breaktimer.sh stop # stop it +breaktimer.sh restart # stop + start +breaktimer.sh pause # freeze the countdown +breaktimer.sh resume # unfreeze +breaktimer.sh toggle # pause/resume in one command +breaktimer.sh status # print state, phase, seconds remaining +``` + +(`breaktimer.sh run` is the internal loop — don't call it directly; it will +refuse if a daemon is already running.) + +Auto-start on login by adding `breaktimer.sh start` to your compositor's +autostart (e.g. Hyprland `exec-once`, Sway `exec`). + +## Configuration + +Edit the variables at the top of `breaktimer.sh`: + +| Variable | Meaning | +|-------------------------------|-------------------------------------------| +| `MICRO_MIN` / `BREAK_MIN` | work block / micro-pause length (minutes) | +| `LONG_MIN` / `LONG_EVERY` | long-pause length / every N-th block | +| `WORK_START` / `WORK_STOP` | work-hours window (`HH:MM`); outside it the work countdown freezes | +| `URGENCY_MICRO` / `URGENCY_LONG` | dunst urgency (`low`/`normal`/`critical`) | +| `SOUND_DIR`, `SOUND_*`, `SYS_SOUND_*` | sound files (see above) | + +## Waybar integration + +Three pieces: + +- **`waybar-breaktimer.sh`** — emits JSON (`{text, class, tooltip}`) that Waybar + renders. Reads the daemon's state files; no recalculation. +- **`waybar-breaktimer.config.jsonc`** — the `custom/breaktimer` module. +- **`breaktimer.css`** — phase colors (Catppuccin). + +### 1. Add the module + +Paste the inner block of `waybar-breaktimer.config.jsonc` into your +`~/.config/waybar/config` modules, then add `"custom/breaktimer"` to one of your +`modules-left/center/right` arrays: + +```jsonc +"custom/breaktimer": { + "exec": "~/bin/waybar-breaktimer.sh", + "return-type": "json", + "interval": 5, + "on-click": "~/bin/breaktimer.sh toggle", // left-click: pause/resume + "on-click-right": "~/bin/breaktimer.sh restart", // right-click: restart + "tooltip": true +} +``` + +### 2. Add the styling + +Append `breaktimer.css` to `~/.config/waybar/style.css`. It colors the module by +phase: + +| Class | Color | Meaning | +|-------------|--------|-------------------| +| `working` | green | working | +| `breaking` | blue | micro-pause | +| `longbreak` | purple | long pause | +| `paused` | yellow | manually paused | +| `stopped` | grey | daemon not running | + +### 3. Reload + +```bash +breaktimer.sh start +killall -SIGUSR2 waybar # reload Waybar +``` + +Left-click the module to pause/resume, right-click to restart. diff --git a/breaktimer.css b/breaktimer.css new file mode 100644 index 0000000..8446531 --- /dev/null +++ b/breaktimer.css @@ -0,0 +1,27 @@ +/* breaktimer.css - incolla queste righe nel tuo ~/.config/waybar/style.css */ +/* Niente commenti // (GTK CSS accetta solo la sintassi slash-star). */ + +#custom-breaktimer { + padding: 0 10px; + margin: 0 2px; +} + +#custom-breaktimer.working { + color: #a6e3a1; +} + +#custom-breaktimer.breaking { + color: #89b4fa; +} + +#custom-breaktimer.longbreak { + color: #cba6f7; +} + +#custom-breaktimer.paused { + color: #f9e2af; +} + +#custom-breaktimer.stopped { + color: #6c7086; +} diff --git a/breaktimer.sh b/breaktimer.sh new file mode 100755 index 0000000..483f0b4 --- /dev/null +++ b/breaktimer.sh @@ -0,0 +1,234 @@ +#!/bin/bash +# +# breaktimer.sh - reminder di micro-pause per spezzare le sessioni al PC +# +# Macchina a stati: working -> (notifica) -> breaking/longbreak -> working +# La pausa NON erode il tempo di lavoro. Pausa manuale congela il countdown. +# +# Modello tempo: si salva REMAINING (secondi rimanenti della fase corrente), +# decrementato a ogni tick solo quando si lavora/conta. Cosi' la pausa manuale +# congela davvero e il countdown Waybar e' sempre coerente. +# +# Uso: breaktimer.sh start|stop|restart|pause|resume|toggle|status +# breaktimer.sh run (interno) +# +# Dipendenze: dunst (notify-send), pipewire (pw-play / paplay), coreutils. + +# ---------- Configurazione ---------- +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" + +SOUND_DIR="$HOME/.local/share/sounds/modern-minimal-ui-sounds/stereo" +SOUND_MICRO="" +SOUND_LONG="" +SOUND_BACK="" +SYS_SOUND_MICRO="$SOUND_DIR/message-new-instant.oga" +SYS_SOUND_LONG="$SOUND_DIR/alarm-clock-elapsed.oga" +SYS_SOUND_BACK="$SOUND_DIR/service-login.oga" +# ------------------------------------ + +RUNTIME="${XDG_RUNTIME_DIR:-/tmp}" +PID_FILE="$RUNTIME/breaktimer.pid" +STATE_FILE="$RUNTIME/breaktimer.state" # running|paused +PHASE_FILE="$RUNTIME/breaktimer.phase" # working|breaking|longbreak +REMAIN_FILE="$RUNTIME/breaktimer.remain" # secondi rimanenti della fase +TICK=5 + +now_epoch() { date +%s; } +hm_to_epoch() { date -d "$(date +%F) $1" +%s; } + +in_work_window() { + local n start stop + n=$(now_epoch); start=$(hm_to_epoch "$WORK_START"); stop=$(hm_to_epoch "$WORK_STOP") + [ "$n" -ge "$start" ] && [ "$n" -lt "$stop" ] +} + +is_running() { + [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null +} + +play_sound() { + local file="$1" + { [ -z "$file" ] || [ ! -f "$file" ]; } && return 0 + if command -v pw-play >/dev/null 2>&1; then pw-play "$file" >/dev/null 2>&1 & + elif command -v paplay >/dev/null 2>&1; then paplay "$file" >/dev/null 2>&1 & + fi +} +sound_for_micro() { + if [ -n "$SOUND_MICRO" ] && [ -f "$SOUND_MICRO" ]; then play_sound "$SOUND_MICRO" + else play_sound "$SYS_SOUND_MICRO"; fi +} +sound_for_long() { + if [ -n "$SOUND_LONG" ] && [ -f "$SOUND_LONG" ]; then play_sound "$SOUND_LONG" + else play_sound "$SYS_SOUND_LONG"; fi +} +sound_for_back() { + if [ -n "$SOUND_BACK" ] && [ -f "$SOUND_BACK" ]; then play_sound "$SOUND_BACK" + else play_sound "$SYS_SOUND_BACK"; fi +} + +notify_micro() { + notify-send -u "$URGENCY_MICRO" -a "breaktimer" \ + "🚶 Micro-pausa ($BREAK_MIN min)" \ + "Alzati e muoviti: camminata, squat, mobilita'. Occhi: guarda lontano 20s." + sound_for_micro +} +notify_long() { + notify-send -u "$URGENCY_LONG" -a "breaktimer" \ + "⏸️ Pausa lunga ($LONG_MIN min)" \ + "Stacca davvero. Cammina, bevi, allunga la schiena." + sound_for_long +} +notify_back() { + notify-send -u "low" -a "breaktimer" \ + "▶️ Si riparte" "Pausa finita, blocco di lavoro da $MICRO_MIN min." + sound_for_back +} + +# ---------- Loop principale ---------- +run_loop() { + # singleton: rifiuta se un altro daemon vivo possiede gia' il PID file. + # Evita due loop che si calpestano gli stessi file di stato. + local owner + owner="$(cat "$PID_FILE" 2>/dev/null)" + if [ -n "$owner" ] && [ "$owner" != "$$" ] && kill -0 "$owner" 2>/dev/null; then + echo "breaktimer: gia' in esecuzione (PID $owner), run annullato" >&2 + exit 1 + fi + echo "$$" > "$PID_FILE" + echo "running" > "$STATE_FILE" + echo "working" > "$PHASE_FILE" + local count=0 + local work_sec=$((MICRO_MIN * 60)) + local break_sec=$((BREAK_MIN * 60)) + local long_sec=$((LONG_MIN * 60)) + local remaining=$work_sec + echo "$remaining" > "$REMAIN_FILE" + + # cleanup: NON tocca PID_FILE. Un vecchio daemon (ucciso da restart) puo' + # eseguire il trap in ritardo, dopo che il nuovo ha gia' scritto il suo PID: + # cancellare il file qui ucciderebbe il riferimento al nuovo daemon. + # is_running usa kill -0, quindi un PID file stantio e' innocuo; lo + # sovrascrive il prossimo start. + cleanup() { + echo "stopped" > "$STATE_FILE"; echo "stopped" > "$PHASE_FILE" + rm -f "$REMAIN_FILE" + exit 0 + } + trap cleanup TERM INT + + while true; do + sleep "$TICK" + + # pausa manuale: NON decrementare, lascia remaining congelato + [ "$(cat "$STATE_FILE" 2>/dev/null)" = "paused" ] && continue + + local phase="$(cat "$PHASE_FILE" 2>/dev/null)" + + # fuori finestra oraria, solo in 'working': congela + if [ "$phase" = "working" ] && ! in_work_window; then + continue + fi + + # decrementa il tempo rimanente della fase + remaining=$(( remaining - TICK )) + + if [ "$remaining" -gt 0 ]; then + echo "$remaining" > "$REMAIN_FILE" + continue + fi + + # fase scaduta: transizione + case "$phase" in + working) + count=$((count + 1)) + if [ "$((count % LONG_EVERY))" -eq 0 ]; then + notify_long + echo "longbreak" > "$PHASE_FILE" + remaining=$long_sec + else + notify_micro + echo "breaking" > "$PHASE_FILE" + remaining=$break_sec + fi + ;; + breaking|longbreak) + notify_back + echo "working" > "$PHASE_FILE" + remaining=$work_sec + ;; + *) + echo "working" > "$PHASE_FILE" + remaining=$work_sec + ;; + esac + echo "$remaining" > "$REMAIN_FILE" + done +} + +# ---------- Gestione daemon ---------- +start_daemon() { + if is_running; then + echo "breaktimer: gia' in esecuzione (PID $(cat "$PID_FILE"))" + return 1 + fi + # il loop scrive da solo il proprio PID ($$) dopo il guard singleton: + # setsid fa fork, quindi $! qui non e' il PID reale del loop. + setsid "$0" run >/dev/null 2>&1 < /dev/null & + # attendi che il loop pubblichi il suo PID + for _ in 1 2 3 4 5 6 7 8 9 10; do + is_running && break + sleep 0.2 + done + if is_running; then + echo "breaktimer: avviato in background (PID $(cat "$PID_FILE"))" + else + echo "breaktimer: avvio fallito" >&2 + return 1 + fi +} +stop_daemon() { + if is_running; then + kill -TERM "$(cat "$PID_FILE")" 2>/dev/null + rm -f "$PID_FILE" "$REMAIN_FILE" + echo "stopped" > "$STATE_FILE"; echo "stopped" > "$PHASE_FILE" + echo "breaktimer: fermato" + else + echo "stopped" > "$STATE_FILE"; echo "stopped" > "$PHASE_FILE" + rm -f "$PID_FILE" "$REMAIN_FILE" + echo "breaktimer: non era in esecuzione" + fi +} + +case "$1" in + start) start_daemon ;; + stop) stop_daemon ;; + restart) stop_daemon; sleep 1; start_daemon ;; + run) run_loop ;; + pause) echo "paused" > "$STATE_FILE"; echo "breaktimer: sospeso" ;; + resume) echo "running" > "$STATE_FILE"; echo "breaktimer: ripreso" ;; + toggle) + if [ "$(cat "$STATE_FILE" 2>/dev/null)" = "paused" ]; then + echo "running" > "$STATE_FILE"; echo "breaktimer: ripreso" + else + echo "paused" > "$STATE_FILE"; echo "breaktimer: sospeso" + fi + ;; + status) + if is_running; then + echo "breaktimer: attivo (PID $(cat "$PID_FILE")), stato: $(cat "$STATE_FILE" 2>/dev/null), fase: $(cat "$PHASE_FILE" 2>/dev/null), rimane: $(cat "$REMAIN_FILE" 2>/dev/null)s" + else + echo "breaktimer: non in esecuzione" + fi + ;; + *) + echo "uso: $0 [start|stop|restart|pause|resume|toggle|status]" + exit 1 + ;; +esac diff --git a/waybar-breaktimer.config.jsonc b/waybar-breaktimer.config.jsonc new file mode 100644 index 0000000..a86bf5e --- /dev/null +++ b/waybar-breaktimer.config.jsonc @@ -0,0 +1,43 @@ +// ============================================================ +// CONFIG WAYBAR - modulo breaktimer +// IMPORTANTE: se lo salvi come file separato da includere, le +// graffe esterne { } DEVONO esserci (vedi sotto). Se invece lo +// incolli dentro il tuo config principale, togli le graffe esterne +// e metti solo la riga "custom/breaktimer": { ... } tra i moduli. +// ============================================================ + +{ + "custom/breaktimer": { + "exec": "~/bin/waybar-breaktimer.sh", + "return-type": "json", + "interval": 5, + "on-click": "~/bin/breaktimer.sh toggle", + "on-click-right": "~/bin/breaktimer.sh restart", + "tooltip": true + } +} + +/* ============================================================ + CSS (aggiungi a ~/.config/waybar/style.css) + Adatta i colori ai tuoi token. + ============================================================ + +#custom-breaktimer { + padding: 0 10px; + margin: 0 2px; + border-radius: 6px; +} + +#custom-breaktimer.working { color: #a6e3a1; } // verde: stai lavorando +#custom-breaktimer.breaking { color: #89b4fa; } // blu: micro-pausa in corso +#custom-breaktimer.longbreak { color: #cba6f7; } // viola: pausa lunga +#custom-breaktimer.paused { color: #f9e2af; } // giallo: pausa manuale +#custom-breaktimer.stopped { color: #6c7086; } // grigio: fermo + +// opzionale: lampeggio durante la pausa per attirare l'occhio +#custom-breaktimer.breaking, +#custom-breaktimer.longbreak { + // animation: blink 1s steps(2) infinite; +} + + ============================================================ */ diff --git a/waybar-breaktimer.sh b/waybar-breaktimer.sh new file mode 100755 index 0000000..b445893 --- /dev/null +++ b/waybar-breaktimer.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# +# waybar-breaktimer.sh - modulo custom Waybar per breaktimer +# Legge il tempo rimanente gia' congelato dal daemon (no calcolo next-now). + +RUNTIME="${XDG_RUNTIME_DIR:-/tmp}" +PID_FILE="$RUNTIME/breaktimer.pid" +STATE_FILE="$RUNTIME/breaktimer.state" +PHASE_FILE="$RUNTIME/breaktimer.phase" +REMAIN_FILE="$RUNTIME/breaktimer.remain" + +is_running() { + [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null +} + +if ! is_running; then + printf '{"text":"","tooltip":"breaktimer: fermo (click dx per avviare)","class":"stopped"}\n' + exit 0 +fi + +state="$(cat "$STATE_FILE" 2>/dev/null)" +phase="$(cat "$PHASE_FILE" 2>/dev/null)" + +rem="$(cat "$REMAIN_FILE" 2>/dev/null)" +[ -z "$rem" ] && rem=0 +[ "$rem" -lt 0 ] && rem=0 +countdown=$(printf '%d:%02d' $(( rem / 60 )) $(( rem % 60 ))) + +if [ "$state" = "paused" ]; then + printf '{"text":" %s","tooltip":"breaktimer in pausa (click sx per riprendere)","class":"paused"}\n' "$countdown" + exit 0 +fi + +case "$phase" in + working) + printf '{"text":" %s","tooltip":"lavoro: pausa tra %s (sx: pausa, dx: stop/restart)","class":"working"}\n' "$countdown" "$countdown" + ;; + breaking) + printf '{"text":" %s","tooltip":"micro-pausa: muoviti! ripresa tra %s","class":"breaking"}\n' "$countdown" "$countdown" + ;; + longbreak) + printf '{"text":" %s","tooltip":"pausa lunga: stacca! ripresa tra %s","class":"longbreak"}\n' "$countdown" "$countdown" + ;; + *) + printf '{"text":" %s","tooltip":"breaktimer attivo","class":"working"}\n' "$countdown" + ;; +esac |
