aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-27 19:39:06 +0200
committerDanilo M. <danix@danix.xyz>2026-09-27 19:39:06 +0200
commit38127fcdb57f51be31df637147c5db31bad9e10f (patch)
treeff2868a61372d3f85845637ba4cd3494244bbda5 /docs
downloadcal-notif-38127fcdb57f51be31df637147c5db31bad9e10f.tar.gz
cal-notif-38127fcdb57f51be31df637147c5db31bad9e10f.zip
Add GPLv2 license and cal-notif design spec
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/specs/2026-09-27-cal-notif-design.md170
1 files changed, 170 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-09-27-cal-notif-design.md b/docs/superpowers/specs/2026-09-27-cal-notif-design.md
new file mode 100644
index 0000000..53138d6
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-27-cal-notif-design.md
@@ -0,0 +1,170 @@
+# cal-notif design
+
+Date: 2026-09-27
+
+## Goal
+
+A desktop daemon that notifies of upcoming calendar events. Each event's own
+alarms (VALARM) set the cadence. Events without alarms can get custom
+notification offsets, set from a rofi picker. Every notification is a popup
+plus a spoken announcement rendered locally.
+
+## Scope
+
+In: VEVENTs from local `.ics` files, popups with a snooze action, local TTS,
+a rofi picker for per-event overrides.
+
+Out: VTODO, network access of any kind (vdirsyncer already syncs), EMAIL and
+AUDIO alarm actions (every alarm becomes popup + voice), persistent
+fired-alarm state.
+
+## Stack
+
+- One Python file, `cal-notif`, run by system `/usr/bin/python3`.
+- Libraries already on the system: `icalendar`, `dateutil` (RRULE parsing),
+ `kokoro_onnx` (TTS). Stdlib `tomllib` for config, `zoneinfo` for timezones.
+- External programs: `notify-send`, `rofi`, `aplay`.
+- License GPLv2 (v2-only).
+
+## Interface
+
+- `cal-notif daemon`: long-running loop. Started from the compositor's
+ autostart (no systemd on the target system).
+- `cal-notif pick`: rofi picker, bound to a key.
+- `cal-notif say "<text>"`: speak text through the configured voice and exit.
+ The daemon runs this as a subprocess; it doubles as a manual voice test.
+
+## Config
+
+`~/.config/cal-notif/config.toml`. Every key has a built-in default, so a
+missing file works (VALARMs only, voice on).
+
+```toml
+calendars_dir = "~/.local/share/calendars" # vdirsyncer filesystem storage
+snooze = "5m"
+late = "10m" # deliver alarms missed by at most this much
+
+[defaults] # per calendar directory name, only for events with no VALARM
+birthdays = ["1d", "9h"]
+"*" = [] # any other calendar: silent unless VALARM
+
+[voice]
+enabled = true
+model = "/data/voice-models/kokoro/kokoro-v1.0.onnx"
+voices = "/data/voice-models/kokoro/voices-v1.0.bin"
+lang = "it"
+blend = { if_sara = 0.8, af_bella = 0.2 }
+lead_silence = 0.3 # seconds of silence before speech, see Voice
+say = "Tra {in}: {summary}"
+say_now = "Adesso: {summary}"
+```
+
+Per-event overrides live in a separate file,
+`~/.config/cal-notif/overrides.toml`, written only by `pick` (hand edits
+also work). Keeping them apart means `pick` never rewrites the hand-edited
+config and its comments.
+
+```toml
+"some-uid@example.org" = ["1h", "10m"] # keyed by event UID; replaces VALARMs
+"other-uid@example.org" = [] # silenced
+```
+
+Durations are strings of `<n>d`, `<n>h`, `<n>m` parts, e.g. `"1d 2h 15m"`.
+A calendar in `[defaults]` may also set `voice = false` via a table form
+(`birthdays = { offsets = ["1d"], voice = false }`).
+
+## Alarm precedence
+
+For each event occurrence, the offsets are the first of:
+
+1. `overrides.toml[UID]`, if present (an empty list silences the event).
+2. The event's own VALARM triggers: relative (`-PT15M`, relative to start or
+ end) and absolute (`VALUE=DATE-TIME`).
+3. `[defaults][<calendar>]`, else `[defaults]["*"]`.
+
+## Daemon loop
+
+Every 30 s:
+
+1. **Rescan** the `.ics` files when any calendar directory's mtime changed
+ (vdirsyncer rewrites files there), and when the config or overrides file's
+ mtime changed.
+2. **Expand** events into concrete occurrences within a window
+ `[now, now + largest offset in use + 1 day]`. The window follows the
+ largest offset, so a "1 week before" alarm is never dropped.
+ - RRULE via `dateutil.rrule`, EXDATE removes occurrences.
+ - A VEVENT with RECURRENCE-ID replaces the occurrence it names, with its
+ own summary, time and alarms.
+ - All-day events (`VALUE=DATE`) start at local midnight; offsets count
+ from there.
+ - Floating times are local time.
+ The result is a sorted list of `(fire_time, occurrence, offset)`,
+ recomputed only on rescan.
+3. **Fire** every entry with `last_tick < fire_time <= now`, skipping those
+ older than `now - late` and those whose occurrence already started (except
+ offset 0, the "now" alarm).
+4. **Notify**: spawn `notify-send --wait --app-name=cal-notif
+ --action=snooze=Snooze --action=dismiss=Dismiss` with summary, start
+ time, location. Urgency critical for the offset-0 alarm. Spawn
+ `cal-notif say` alongside. Neither blocks the loop; finished children are
+ polled each tick.
+5. **Snooze**: when a `notify-send` child prints `snooze`, queue a one-off
+ re-fire at `now + snooze` for the same occurrence. It speaks again.
+
+The notification server on the target system advertises `actions` and
+returns the clicked action id to `notify-send --wait` (verified 2026-09-27).
+
+Deliberate simplification: fired alarms are kept in memory only. A restart
+within the `late` window repeats those popups once.
+
+## Voice
+
+`cal-notif say` loads Kokoro, builds the voice from the blend of style
+vectors, synthesizes the text and pipes 16-bit mono PCM to
+`aplay -q -r <rate> -f S16_LE -c 1`.
+
+- Kokoro loads per announcement (about 0.3 s) instead of staying resident
+ in the daemon: alarms are rare, and a resident model costs 300+ MB.
+- **Leading silence**: `lead_silence` seconds of zero samples are written
+ before the speech. A freshly started playback stream wakes the suspended
+ audio sink and loses its first ~0.2 s, which ate the first word of
+ announcements in a previous project. Default 0.3 s, tunable per machine.
+- `{in}` is the offset spoken in words ("dieci minuti", "un giorno e due
+ ore"), from a small Italian number-to-words function covering 1-99 plus
+ units. Kokoro reads "10m" badly.
+- If voice is disabled, or the model files are missing, only the popup
+ is shown; a missing model is reported once on stderr.
+
+## Picker (`pick`)
+
+1. rofi `-dmenu` lists occurrences in the next 30 days:
+ `dd.mm HH:MM · calendar · summary · [current offsets]`.
+2. Second rofi menu for the chosen event: presets `10m`, `30m`, `1h`, `1d`,
+ `1d 1h`, `none` (silence), `reset` (drop override). Free text is accepted
+ and parsed as durations; invalid input shows a rofi error and re-prompts.
+3. `overrides.toml` is rewritten whole (flat `"uid" = [..]` lines, trivial
+ to emit without a TOML writer) via a temp file and rename. The daemon
+ picks it up on its next tick through the mtime check.
+
+## Error handling
+
+- An unparsable `.ics` file is skipped with one stderr line naming it; the
+ others still load.
+- An invalid config at startup exits with the error. An invalid config on
+ reload keeps the previous one and logs.
+- `notify-send`, `aplay` or `rofi` missing: fail at startup for the daemon
+ (`notify-send`) or the picker (`rofi`); `aplay` missing disables voice.
+
+## Testing
+
+One `test_cal_notif.py` with plain asserts, fixtures as inline `.ics`
+strings:
+
+- duration parsing and Italian spoken form
+- precedence: override, VALARM, calendar default, `*`
+- expansion: RRULE, EXDATE, RECURRENCE-ID, all-day, window sized by largest
+ offset
+- fire window: `late` cutoff, started occurrences skipped, snooze re-fire
+
+Voice and popups are checked by hand: `cal-notif say "prova"` and an event
+created a few minutes ahead.