aboutsummaryrefslogtreecommitdiff
path: root/patches
diff options
context:
space:
mode:
Diffstat (limited to 'patches')
-rw-r--r--patches/README133
-rw-r--r--patches/ttym-bar-styles-2.0.diff155
-rw-r--r--patches/ttym-config-file-2.0.diff237
-rw-r--r--patches/ttym-flash-2.0.diff113
-rw-r--r--patches/ttym-full-2.0.diff1057
-rw-r--r--patches/ttym-hooks-2.0.diff127
-rw-r--r--patches/ttym-logging-2.0.diff447
-rw-r--r--patches/ttym-notify-2.0.diff232
-rw-r--r--patches/ttym-persist-alert-2.0.diff111
9 files changed, 2612 insertions, 0 deletions
diff --git a/patches/README b/patches/README
new file mode 100644
index 0000000..cd4e12a
--- /dev/null
+++ b/patches/README
@@ -0,0 +1,133 @@
+ttym patches
+============
+
+Each patch is a unified diff against the pristine base. Apply with:
+
+ cd ttym
+ patch -p1 < patches/ttym-<name>-2.0.diff
+
+Then rebuild:
+
+ rm -f config.h ; make
+
+To get every feature at once, skip the individual patches entirely and
+apply ttym-full-2.0.diff -- one combined diff, no conflicts to resolve.
+See "The full build" below.
+
+
+The full build
+--------------
+
+ ttym-full-2.0.diff
+ Every feature in a single patch: persist-alert, flash, notify,
+ logging, hooks, bar-styles and config-file, with all inter-patch
+ conflicts already resolved. Apply it to the pristine base and
+ rebuild:
+
+ patch -p1 < patches/ttym-full-2.0.diff
+ rm -f config.h ; make
+
+ This is the recommended route to the full-featured build. It is
+ equivalent to stacking all seven feature patches below and hand-
+ resolving every collision, but deterministic and conflict-free.
+ Use the individual patches only when you want a specific subset.
+
+
+Available patches
+-----------------
+
+ ttym-persist-alert-2.0.diff
+ After a countdown completes, keep ringing the bell every ~1.5s
+ until any key is pressed. Foreground-only; backgrounded runs
+ fall through. Adds: --no-persist flag, ALERT_PERSIST knob.
+
+ ttym-flash-2.0.diff [requires persist-alert]
+ Add reverse-video terminal flash to the persistent alert loop.
+ Adds: --flash on|off flag, FLASH knob.
+
+ ttym-notify-2.0.diff
+ One-shot desktop notification and sound on countdown completion,
+ plus a MESSAGE positional argument piped through {title}/{msg}
+ placeholders. Adds: TITLE, NOTIFY_CMD, SOUND_CMD knobs.
+
+ ttym-logging-2.0.diff
+ TSV log at ~/.config/ttym/ttym.log, +tag extraction, the -r
+ reader (-n, --raw), and --stats (today|week|month|all). Adds
+ one-shot directory migration from ~/.config/timer/.
+ Adds: -c TEXT flag.
+
+ ttym-hooks-2.0.diff
+ Optional executables at ~/.config/ttym/hooks/{on_start,on_done,
+ on_quit} invoked with: mode duration_seconds comment.
+ Adds: -c TEXT flag (passed as 3rd hook arg).
+
+ ttym-bar-styles-2.0.diff
+ Eight built-in bar styles (unicode, ascii, hash, dots, line,
+ block, arrow, minimal) with locale-aware auto-detect.
+ Adds: --bar STYLE flag, BAR_STYLE knob.
+
+ ttym-config-file-2.0.diff [capstone -- see Dependencies]
+ Runtime config at ~/.config/ttym/config (key = value), auto-
+ generated and self-documenting on first run. Exposes eight keys:
+ bar_fill, bar_empty, bar_style, flash, alert_persist, title,
+ notify_cmd, sound_cmd. A system-wide /etc/ttym.conf is read
+ first. Because it wires up knobs owned by other patches, it must
+ be applied LAST.
+
+
+Dependencies
+------------
+
+flash depends on persist-alert.
+
+config-file is the capstone: it reads knobs introduced by persist-
+alert, flash, notify, logging and bar-styles, and extends a usage()
+line added by hooks -- so all six must be applied before it. Apply
+config-file last.
+
+All other patches are independent of each other and of the base.
+
+
+Stacking
+--------
+
+The simplest route to the full build is ttym-full-2.0.diff (see "The
+full build" above). The notes here apply only when stacking the
+feature patches by hand to assemble a subset.
+
+Every patch in this directory applies and compiles cleanly against the
+pristine base -- except flash, which builds on persist-alert, and
+config-file, the capstone, which builds on six other patches (see
+Dependencies). That is the property the verification step guarantees.
+
+Combining multiple optional patches will produce conflicts at shared
+regions: the Config struct, usage(), the getopt() option string, and
+in some cases #include lines. This is normal suckless territory --
+resolve by hand, or apply ttym-full-2.0.diff instead. The base is laid
+out so most patches add their new code in fresh sections (before
+usage(), before main()) where they do not collide; the inevitable
+collisions are in points where every patch must add a line or field.
+
+Recommended order when stacking:
+
+ persist-alert -> flash -> notify -> logging -> hooks ->
+ bar-styles -> config-file
+
+config-file must come last -- after bar-styles in particular, since it
+overrides the resolved bar glyphs. Apply, resolve any .rej files,
+rebuild.
+
+
+Authoring new patches
+---------------------
+
+Match the discipline used here:
+
+ - Add compile-time knobs to config.def.h, never inline in ttym.c.
+ - Update usage() in the same hunk as the option parser change, so
+ `timer -h` never drifts from what the build actually supports.
+ - Verify with `patch -p1` against a fresh copy of the base -- or,
+ for a patch with prerequisites, the base plus those -- and confirm
+ the result compiles before publishing.
+ - When the patch set changes, regenerate ttym-full-2.0.diff so the
+ one-shot full build stays in sync.
diff --git a/patches/ttym-bar-styles-2.0.diff b/patches/ttym-bar-styles-2.0.diff
new file mode 100644
index 0000000..2653cf5
--- /dev/null
+++ b/patches/ttym-bar-styles-2.0.diff
@@ -0,0 +1,155 @@
+diff -ruN a/config.def.h b/config.def.h
+--- a/config.def.h 2026-05-20 12:05:11.699957443 +0200
++++ b/config.def.h 2026-05-20 12:05:11.703957526 +0200
+@@ -3,3 +3,8 @@
+ /* progress bar glyphs (ASCII default) */
+ static const char *const BAR_FILL = ">";
+ static const char *const BAR_EMPTY = "-";
++
++/* default bar style: "" autodetects (unicode if locale is UTF-8, else ascii).
++ * Available: unicode | ascii | hash | dots | line | block | arrow | minimal.
++ */
++static const char *const BAR_STYLE = "";
+diff -ruN a/ttym.c b/ttym.c
+--- a/ttym.c 2026-05-20 12:05:11.699957443 +0200
++++ b/ttym.c 2026-05-20 12:06:26.161496315 +0200
+@@ -5,6 +5,7 @@
+ #include <ctype.h>
+ #include <errno.h>
+ #include <fcntl.h>
++#include <locale.h>
+ #include <poll.h>
+ #include <signal.h>
+ #include <stdarg.h>
+@@ -23,6 +24,7 @@
+ typedef struct {
+ int quiet; /* -q: suppress completion bell */
+ int silent; /* -s: no bell, no extras */
++ char bar_style[32]; /* --bar STYLE */
+ } Config;
+
+ static struct termios oldtios;
+@@ -186,7 +188,8 @@
+ /* ===== drawing ===== */
+
+ static void
+-draw_countdown(long total, long elapsed_ms, int paused, int barw)
++draw_countdown(long total, long elapsed_ms, int paused, int barw,
++ const char *fill_glyph, const char *empty_glyph, int minimal)
+ {
+ if (total <= 0) return;
+ long total_ms = total * 1000L;
+@@ -207,8 +210,15 @@
+ const char *status = paused ? "PAUSED " : " ";
+
+ printf("\r%sT-%s\033[0m \033[2m+%s\033[0m [", color, rem, el);
+- for (int i = 0; i < fill; i++) fputs(BAR_FILL, stdout);
+- for (int i = 0; i < empty; i++) fputs(BAR_EMPTY, stdout);
++ if (minimal) {
++ fputs("\033[7m", stdout);
++ for (int i = 0; i < fill; i++) fputc(' ', stdout);
++ fputs("\033[27m", stdout);
++ for (int i = 0; i < empty; i++) fputc(' ', stdout);
++ } else {
++ for (int i = 0; i < fill; i++) fputs(fill_glyph, stdout);
++ for (int i = 0; i < empty; i++) fputs(empty_glyph, stdout);
++ }
+ printf("] %3ld%% %s", pct, status);
+ fflush(stdout);
+ }
+@@ -226,6 +236,55 @@
+ fflush(stdout);
+ }
+
++/* ===== bar styles ===== */
++
++typedef struct { const char *name, *fill, *empty; } BarStyle;
++
++static const BarStyle bar_styles[] = {
++ { "unicode", "\xe2\x96\x88", "\xe2\x96\x91" }, /* U+2588 U+2591 */
++ { "ascii", "=", "-" },
++ { "hash", "#", "." },
++ { "dots", "\xe2\x80\xa2", "\xc2\xb7" }, /* U+2022 U+00B7 */
++ { "line", "\xe2\x94\x81", "\xe2\x94\x80" }, /* U+2501 U+2500 */
++ { "block", "\xe2\x96\x93", "\xe2\x96\x91" }, /* U+2593 U+2591 */
++ { "arrow", "\xe2\x96\xb6", "\xe2\x96\xb7" }, /* U+25B6 U+25B7 */
++ { "minimal", "", "" },
++ { NULL, NULL, NULL }
++};
++
++static const BarStyle *
++find_bar_style(const char *name)
++{
++ if (!name || !*name) return NULL;
++ for (int i = 0; bar_styles[i].name; i++)
++ if (!strcmp(bar_styles[i].name, name)) return &bar_styles[i];
++ return NULL;
++}
++
++static int
++is_utf8(void)
++{
++ const char *l = setlocale(LC_CTYPE, "");
++ if (!l) return 0;
++ return strstr(l, "UTF-8") || strstr(l, "utf8") || strstr(l, "utf-8");
++}
++
++static void
++resolve_bar(const Config *c, const char **fill, const char **empty, int *minimal)
++{
++ *minimal = 0;
++ const char *name = (c->bar_style[0]) ? c->bar_style : BAR_STYLE;
++ const BarStyle *s = find_bar_style(name);
++ if (!s) s = find_bar_style(is_utf8() ? "unicode" : "ascii");
++ if (s && !strcmp(s->name, "minimal")) {
++ *minimal = 1;
++ *fill = " "; *empty = " ";
++ return;
++ }
++ *fill = s ? s->fill : BAR_FILL;
++ *empty = s ? s->empty : BAR_EMPTY;
++}
++
+ /* ===== usage =====
+ * Patches MUST update this in lockstep with new flags so -h always
+ * reflects the build.
+@@ -243,6 +302,7 @@
+ "FLAGS:\n"
+ " -q no sound\n"
+ " -s silent\n"
++" --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal\n"
+ " -- end of options\n"
+ " -h help\n"
+ "\n"
+@@ -259,6 +319,10 @@
+ int barw = cols - 40;
+ if (barw < 10) barw = 10;
+
++ const char *fill_glyph, *empty_glyph;
++ int minimal = 0;
++ resolve_bar(cfg, &fill_glyph, &empty_glyph, &minimal);
++
+ ttyfd = open("/dev/tty", O_RDONLY);
+ if (ttyfd >= 0 && isatty(ttyfd)) {
+ if (tcgetattr(ttyfd, &oldtios) == 0) {
+@@ -300,7 +364,8 @@
+ draw_stopwatch(elapsed_ms, paused);
+ } else {
+ if (out_tty)
+- draw_countdown(total, elapsed_ms, paused, barw);
++ draw_countdown(total, elapsed_ms, paused, barw,
++ fill_glyph, empty_glyph, minimal);
+ if (!paused && elapsed_ms >= total * 1000L) break;
+ }
+
+@@ -399,6 +464,10 @@
+ new_argv[new_argc++] = argv[i];
+ continue;
+ }
++ if (!past_dd && !strcmp(argv[i], "--bar") && i + 1 < argc) {
++ snprintf(cfg.bar_style, sizeof(cfg.bar_style), "%s", argv[++i]);
++ continue;
++ }
+ new_argv[new_argc++] = argv[i];
+ }
+ new_argv[new_argc] = NULL;
diff --git a/patches/ttym-config-file-2.0.diff b/patches/ttym-config-file-2.0.diff
new file mode 100644
index 0000000..422d724
--- /dev/null
+++ b/patches/ttym-config-file-2.0.diff
@@ -0,0 +1,237 @@
+diff -ruN a/config.def.h b/config.def.h
+--- a/config.def.h 2026-05-20 13:18:06.149571116 +0200
++++ b/config.def.h 2026-05-20 13:19:12.046956614 +0200
+@@ -1,23 +1,29 @@
+ /* See LICENSE file for copyright and license details. */
+
+-/* progress bar glyphs (ASCII default) */
++/* progress bar glyphs (ASCII default).
++ * Runtime config file ~/.config/ttym/config can override these via
++ * bar_fill / bar_empty keys.
++ */
+ static const char *const BAR_FILL = ">";
+ static const char *const BAR_EMPTY = "-";
+
+ /* persistent alert: keep ringing the bell until any key is pressed.
+ * Foreground-only; backgrounded runs always fall through.
+- * Override per-run with --no-persist.
++ * Override per-run with --no-persist, or via the alert_persist key
++ * in the runtime config file (~/.config/ttym/config).
+ */
+ static const int ALERT_PERSIST = 1;
+
+ /* reverse-video flash during the alert loop.
+- * Override per-run with --flash on|off.
++ * Override per-run with --flash on|off, or via the flash key in the
++ * runtime config file (~/.config/ttym/config).
+ */
+ static const int FLASH = 1;
+
+ /* desktop notification on countdown completion.
+ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded.
+ * Set NULL or "" to disable.
++ * The runtime config file can override these via title / notify_cmd keys.
+ */
+ static const char *const TITLE = "Timer";
+ static const char *const NOTIFY_CMD = "notify-send -u critical {title} {msg}";
+@@ -25,6 +31,7 @@
+ /* sound on countdown completion. If SOUND_CMD is "" the binary autodetects
+ * a player (paplay/aplay/mpv/ffplay) and plays a default freedesktop sound.
+ * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD.
++ * The runtime config file can override this via the sound_cmd key.
+ */
+ static const char *const SOUND_CMD = "";
+
+@@ -35,5 +42,6 @@
+
+ /* default bar style: "" autodetects (unicode if locale is UTF-8, else ascii).
+ * Available: unicode | ascii | hash | dots | line | block | arrow | minimal.
++ * The runtime config file can override this via the bar_style key.
+ */
+ static const char *const BAR_STYLE = "";
+diff -ruN a/ttym.c b/ttym.c
+--- a/ttym.c 2026-05-20 13:18:06.149571116 +0200
++++ b/ttym.c 2026-05-20 13:19:12.046956614 +0200
+@@ -30,10 +30,12 @@
+ int alert_persist; /* alert loop until keypress; --no-persist disables */
+ int flash; /* reverse-video flash during alert loop */
+ char comment[512]; /* -c TEXT: logged with each entry */
++ char bar_fill[16]; /* override (utf-8 ok); from runtime config */
++ char bar_empty[16]; /* override */
+ char bar_style[32]; /* --bar STYLE */
+- const char *title;
+- const char *notify_cmd;
+- const char *sound_cmd;
++ char title[256]; /* override; from runtime config */
++ char notify_cmd[512]; /* override; from runtime config */
++ char sound_cmd[512]; /* override; from runtime config */
+ char msg[1024];
+ } Config;
+
+@@ -409,7 +411,7 @@
+ static void
+ notify_and_sound(const Config *c)
+ {
+- if (c->notify_cmd && c->notify_cmd[0] && command_exists("notify-send")) {
++ if (c->notify_cmd[0] && command_exists("notify-send")) {
+ char **a;
+ if (build_argv(c->notify_cmd, c, &a) > 0) {
+ spawn_bg(a);
+@@ -417,7 +419,7 @@
+ }
+ }
+ if (!c->quiet) {
+- if (c->sound_cmd && c->sound_cmd[0]) {
++ if (c->sound_cmd[0]) {
+ char **a;
+ if (build_argv(c->sound_cmd, c, &a) > 0) {
+ spawn_bg(a);
+@@ -832,6 +834,112 @@
+ }
+ }
+
++/* ===== runtime config file (~/.config/ttym/config) =====
++ * key = value lines; '#' comments. Unknown keys ignored.
++ */
++
++static void
++trim(char *s)
++{
++ char *p = s;
++ while (*p && isspace((unsigned char)*p)) p++;
++ if (p != s) memmove(s, p, strlen(p) + 1);
++ size_t n = strlen(s);
++ while (n > 0 && isspace((unsigned char)s[n-1])) s[--n] = '\0';
++}
++
++static void
++ensure_default_config_file(const char *path)
++{
++ if (access(path, F_OK) == 0) return;
++ char dir[512];
++ snprintf(dir, sizeof(dir), "%s", path);
++ char *slash = strrchr(dir, '/');
++ if (slash) { *slash = '\0'; mkdir_p(dir, 0700); }
++ FILE *f = fopen(path, "w");
++ if (!f) return;
++ fputs(
++"# ttym configuration file\n"
++"#\n"
++"# Location : ~/.config/ttym/config (/etc/ttym.conf is read first, if present)\n"
++"# Syntax : key = value -- one entry per line; '#' begins a comment.\n"
++"# A commented-out or absent key keeps its built-in default.\n"
++"# Command-line flags override anything set in this file.\n"
++"#\n"
++"# Every key below is recognised by this build. Uncomment and edit to taste.\n"
++"# ---------------------------------------------------------------------------\n"
++"\n"
++"# Progress-bar glyphs. Set BOTH or NEITHER -- mismatched display widths\n"
++"# break bar alignment. Multi-byte UTF-8 is fine. These override bar_style.\n"
++"# bar_fill = >\n"
++"# bar_empty = -\n"
++"\n"
++"# Named progress-bar style. One of:\n"
++"# unicode ascii hash dots line block arrow minimal\n"
++"# Leave unset to auto-detect (unicode on a UTF-8 locale, otherwise ascii).\n"
++"# bar_style = unicode\n"
++"\n"
++"# Reverse-video flash during the completion alert. on | off\n"
++"# flash = on\n"
++"\n"
++"# Persistent alert: keep ringing the bell until a key is pressed\n"
++"# (foreground runs only). on | off\n"
++"# alert_persist = on\n"
++"\n"
++"# Desktop-notification title.\n"
++"# title = Timer\n"
++"\n"
++"# Desktop-notification command run when a countdown finishes. Tokens are\n"
++"# split on whitespace; {title}/{msg} placeholders expand. Empty disables.\n"
++"# notify_cmd = notify-send -u critical {title} {msg}\n"
++"\n"
++"# Sound played when a countdown finishes. Leave unset to auto-detect a\n"
++"# player (paplay/aplay/mpv/ffplay) and play a default system sound.\n"
++"# Tokens are split on whitespace; {title}/{msg} placeholders expand.\n"
++"# sound_cmd = paplay /usr/share/sounds/freedesktop/stereo/complete.oga\n",
++ f);
++ fclose(f);
++ chmod(path, 0600);
++}
++
++static void
++load_config_file(Config *c, const char *path)
++{
++ FILE *f = fopen(path, "r");
++ if (!f) return;
++ char line[1024];
++ while (fgets(line, sizeof(line), f)) {
++ char *hash = strchr(line, '#');
++ if (hash) *hash = '\0';
++ char *eq = strchr(line, '=');
++ if (!eq) continue;
++ *eq = '\0';
++ char *k = line, *v = eq + 1;
++ trim(k); trim(v);
++ if (!*k) continue;
++ if (!strcmp(k, "bar_fill")) snprintf(c->bar_fill, sizeof(c->bar_fill), "%s", v);
++ else if (!strcmp(k, "bar_empty")) snprintf(c->bar_empty, sizeof(c->bar_empty), "%s", v);
++ else if (!strcmp(k, "bar_style")) snprintf(c->bar_style, sizeof(c->bar_style), "%s", v);
++ else if (!strcmp(k, "flash")) c->flash = parse_bool_str(v);
++ else if (!strcmp(k, "alert_persist")) c->alert_persist = parse_bool_str(v);
++ else if (!strcmp(k, "title")) snprintf(c->title, sizeof(c->title), "%s", v);
++ else if (!strcmp(k, "notify_cmd")) snprintf(c->notify_cmd, sizeof(c->notify_cmd), "%s", v);
++ else if (!strcmp(k, "sound_cmd")) snprintf(c->sound_cmd, sizeof(c->sound_cmd), "%s", v);
++ }
++ fclose(f);
++}
++
++static void
++load_config(Config *c)
++{
++ load_config_file(c, "/etc/ttym.conf");
++ char path[512];
++ if (build_config_path(path, sizeof(path), "config") == 0) {
++ ensure_default_config_file(path);
++ load_config_file(c, path);
++ }
++}
++
+ /* ===== bar styles ===== */
+
+ typedef struct { const char *name, *fill, *empty; } BarStyle;
+@@ -908,7 +1016,8 @@
+ " -h help\n"
+ "\n"
+ "controls: [space] pause/resume [q | Ctrl+C] quit\n"
+-"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n");
++"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n"
++"config: ~/.config/ttym/config (auto-generated on first run)\n");
+ }
+
+ /* ===== run loop ===== */
+@@ -924,6 +1033,10 @@
+ const char *fill_glyph, *empty_glyph;
+ int minimal = 0;
+ resolve_bar(cfg, &fill_glyph, &empty_glyph, &minimal);
++ /* explicit per-glyph overrides from the runtime config file
++ * (bar_fill / bar_empty) win over the named --bar style. */
++ if (cfg->bar_fill[0]) fill_glyph = cfg->bar_fill;
++ if (cfg->bar_empty[0]) empty_glyph = cfg->bar_empty;
+
+ ttyfd = open("/dev/tty", O_RDONLY);
+ if (ttyfd >= 0 && isatty(ttyfd)) {
+@@ -1075,10 +1188,11 @@
+ Config cfg = {0};
+ cfg.alert_persist = ALERT_PERSIST;
+ cfg.flash = FLASH;
+- cfg.title = TITLE;
+- cfg.notify_cmd = NOTIFY_CMD;
+- cfg.sound_cmd = SOUND_CMD;
++ snprintf(cfg.title, sizeof(cfg.title), "%s", TITLE ? TITLE : "");
++ snprintf(cfg.notify_cmd, sizeof(cfg.notify_cmd), "%s", NOTIFY_CMD ? NOTIFY_CMD : "");
++ snprintf(cfg.sound_cmd, sizeof(cfg.sound_cmd), "%s", SOUND_CMD ? SOUND_CMD : "");
+ snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up");
++ load_config(&cfg);
+
+ /* Pre-strip long-form flags before getopt. Stop at "--". */
+ int new_argc = 0;
diff --git a/patches/ttym-flash-2.0.diff b/patches/ttym-flash-2.0.diff
new file mode 100644
index 0000000..0ee30b5
--- /dev/null
+++ b/patches/ttym-flash-2.0.diff
@@ -0,0 +1,113 @@
+diff -ruN a/config.def.h b/config.def.h
+--- a/config.def.h 2026-05-20 12:14:52.639963552 +0200
++++ b/config.def.h 2026-05-20 12:14:52.639963552 +0200
+@@ -9,3 +9,8 @@
+ * Override per-run with --no-persist.
+ */
+ static const int ALERT_PERSIST = 1;
++
++/* reverse-video flash during the alert loop.
++ * Override per-run with --flash on|off.
++ */
++static const int FLASH = 1;
+diff -ruN a/ttym.c b/ttym.c
+--- a/ttym.c 2026-05-20 12:14:52.639963552 +0200
++++ b/ttym.c 2026-05-20 12:17:37.203364538 +0200
+@@ -11,6 +11,7 @@
+ #include <stdio.h>
+ #include <stdlib.h>
+ #include <string.h>
++#include <strings.h>
+ #include <sys/ioctl.h>
+ #include <termios.h>
+ #include <time.h>
+@@ -24,6 +25,7 @@
+ int quiet; /* -q: suppress completion bell */
+ int silent; /* -s: no bell, no extras */
+ int alert_persist; /* alert loop until keypress; --no-persist disables */
++ int flash; /* reverse-video flash during alert loop */
+ } Config;
+
+ static struct termios oldtios;
+@@ -81,6 +83,13 @@
+ interrupted = 1;
+ }
+
++static int
++parse_bool_str(const char *v)
++{
++ return !strcasecmp(v, "true") || !strcasecmp(v, "yes") ||
++ !strcmp(v, "1") || !strcasecmp(v, "on");
++}
++
+ static void
+ restore_tty(void)
+ {
+@@ -88,7 +97,7 @@
+ tcsetattr(ttyfd, TCSANOW, &oldtios);
+ rawset = 0;
+ if (out_tty)
+- fputs("\033[?7h\033[?25h", stdout); /* re-enable wrap, show cursor */
++ fputs("\033[?5l\033[?7h\033[?25h", stdout); /* clear reverse, re-enable wrap, show cursor */
+ fputc('\n', stdout);
+ fflush(stdout);
+ }
+@@ -241,14 +250,17 @@
+ }
+
+ static void
+-alert_loop(void)
++alert_loop(int flash)
+ {
+ fputs("\033[?25h\033[?7h", stdout);
+ int ticks = 0;
+ while (!interrupted) {
+ fputc('\a', stdout);
++ if (flash) fputs("\033[?5h", stdout);
+ fflush(stdout);
+ sleep_ms(150);
++ if (flash) fputs("\033[?5l", stdout);
++ fflush(stdout);
+
+ int acked = 0;
+ for (int i = 0; i < 15 && !interrupted; i++) {
+@@ -287,6 +299,7 @@
+ " -q no sound\n"
+ " -s silent\n"
+ " --no-persist skip alert-until-keypress loop\n"
++" --flash on|off terminal flash during alert loop\n"
+ " -- end of options\n"
+ " -h help\n"
+ "\n"
+@@ -421,7 +434,7 @@
+ }
+ if (!cfg->silent && cfg->alert_persist && is_foreground()) {
+ interrupted = 0;
+- alert_loop();
++ alert_loop(cfg->flash);
+ }
+ }
+
+@@ -436,6 +449,7 @@
+ {
+ Config cfg = {0};
+ cfg.alert_persist = ALERT_PERSIST;
++ cfg.flash = FLASH;
+
+ /* Pre-strip long-form flags before getopt. Stop at "--". */
+ int new_argc = 0;
+@@ -452,6 +466,14 @@
+ cfg.alert_persist = 0;
+ continue;
+ }
++ if (!past_dd && !strncmp(argv[i], "--flash=", 8)) {
++ cfg.flash = parse_bool_str(argv[i] + 8);
++ continue;
++ }
++ if (!past_dd && !strcmp(argv[i], "--flash") && i + 1 < argc) {
++ cfg.flash = parse_bool_str(argv[++i]);
++ continue;
++ }
+ new_argv[new_argc++] = argv[i];
+ }
+ new_argv[new_argc] = NULL;
diff --git a/patches/ttym-full-2.0.diff b/patches/ttym-full-2.0.diff
new file mode 100644
index 0000000..a344275
--- /dev/null
+++ b/patches/ttym-full-2.0.diff
@@ -0,0 +1,1057 @@
+diff -ruN a/config.def.h b/config.def.h
+--- a/config.def.h 2026-05-20 14:08:08.221029371 +0200
++++ b/config.def.h 2026-05-20 14:08:08.221029371 +0200
+@@ -1,5 +1,47 @@
+ /* See LICENSE file for copyright and license details. */
+
+-/* progress bar glyphs (ASCII default) */
++/* progress bar glyphs (ASCII default).
++ * Runtime config file ~/.config/ttym/config can override these via
++ * bar_fill / bar_empty keys.
++ */
+ static const char *const BAR_FILL = ">";
+ static const char *const BAR_EMPTY = "-";
++
++/* persistent alert: keep ringing the bell until any key is pressed.
++ * Foreground-only; backgrounded runs always fall through.
++ * Override per-run with --no-persist, or via the alert_persist key
++ * in the runtime config file (~/.config/ttym/config).
++ */
++static const int ALERT_PERSIST = 1;
++
++/* reverse-video flash during the alert loop.
++ * Override per-run with --flash on|off, or via the flash key in the
++ * runtime config file (~/.config/ttym/config).
++ */
++static const int FLASH = 1;
++
++/* desktop notification on countdown completion.
++ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded.
++ * Set NULL or "" to disable.
++ * The runtime config file can override these via title / notify_cmd keys.
++ */
++static const char *const TITLE = "Timer";
++static const char *const NOTIFY_CMD = "notify-send -u critical {title} {msg}";
++
++/* sound on countdown completion. If SOUND_CMD is "" the binary autodetects
++ * a player (paplay/aplay/mpv/ffplay) and plays a default freedesktop sound.
++ * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD.
++ * The runtime config file can override this via the sound_cmd key.
++ */
++static const char *const SOUND_CMD = "";
++
++/* log location is ~/.config/ttym/ttym.log (honours $XDG_CONFIG_HOME).
++ * One-shot migration: ~/.config/timer/ -> ~/.config/ttym/ if the new
++ * directory doesn't already exist; legacy log file inside is renamed.
++ */
++
++/* default bar style: "" autodetects (unicode if locale is UTF-8, else ascii).
++ * Available: unicode | ascii | hash | dots | line | block | arrow | minimal.
++ * The runtime config file can override this via the bar_style key.
++ */
++static const char *const BAR_STYLE = "";
+diff -ruN a/ttym.c b/ttym.c
+--- a/ttym.c 2026-05-20 14:08:08.221029371 +0200
++++ b/ttym.c 2026-05-20 14:08:08.221029371 +0200
+@@ -5,13 +5,17 @@
+ #include <ctype.h>
+ #include <errno.h>
+ #include <fcntl.h>
++#include <locale.h>
+ #include <poll.h>
+ #include <signal.h>
+ #include <stdarg.h>
+ #include <stdio.h>
+ #include <stdlib.h>
+ #include <string.h>
++#include <strings.h>
+ #include <sys/ioctl.h>
++#include <sys/stat.h>
++#include <sys/wait.h>
+ #include <termios.h>
+ #include <time.h>
+ #include <unistd.h>
+@@ -23,6 +27,16 @@
+ typedef struct {
+ int quiet; /* -q: suppress completion bell */
+ int silent; /* -s: no bell, no extras */
++ int alert_persist; /* alert loop until keypress; --no-persist disables */
++ int flash; /* reverse-video flash during alert loop */
++ char comment[512]; /* -c TEXT: logged with each entry */
++ char bar_fill[16]; /* override (utf-8 ok); from runtime config */
++ char bar_empty[16]; /* override */
++ char bar_style[32]; /* --bar STYLE */
++ char title[256]; /* override; from runtime config */
++ char notify_cmd[512]; /* override; from runtime config */
++ char sound_cmd[512]; /* override; from runtime config */
++ char msg[1024];
+ } Config;
+
+ static struct termios oldtios;
+@@ -80,6 +94,13 @@
+ interrupted = 1;
+ }
+
++static int
++parse_bool_str(const char *v)
++{
++ return !strcasecmp(v, "true") || !strcasecmp(v, "yes") ||
++ !strcmp(v, "1") || !strcasecmp(v, "on");
++}
++
+ static void
+ restore_tty(void)
+ {
+@@ -87,7 +108,7 @@
+ tcsetattr(ttyfd, TCSANOW, &oldtios);
+ rawset = 0;
+ if (out_tty)
+- fputs("\033[?7h\033[?25h", stdout); /* re-enable wrap, show cursor */
++ fputs("\033[?5l\033[?7h\033[?25h", stdout); /* clear reverse, re-enable wrap, show cursor */
+ fputc('\n', stdout);
+ fflush(stdout);
+ }
+@@ -186,7 +207,8 @@
+ /* ===== drawing ===== */
+
+ static void
+-draw_countdown(long total, long elapsed_ms, int paused, int barw)
++draw_countdown(long total, long elapsed_ms, int paused, int barw,
++ const char *fill_glyph, const char *empty_glyph, int minimal)
+ {
+ if (total <= 0) return;
+ long total_ms = total * 1000L;
+@@ -207,8 +229,15 @@
+ const char *status = paused ? "PAUSED " : " ";
+
+ printf("\r%sT-%s\033[0m \033[2m+%s\033[0m [", color, rem, el);
+- for (int i = 0; i < fill; i++) fputs(BAR_FILL, stdout);
+- for (int i = 0; i < empty; i++) fputs(BAR_EMPTY, stdout);
++ if (minimal) {
++ fputs("\033[7m", stdout);
++ for (int i = 0; i < fill; i++) fputc(' ', stdout);
++ fputs("\033[27m", stdout);
++ for (int i = 0; i < empty; i++) fputc(' ', stdout);
++ } else {
++ for (int i = 0; i < fill; i++) fputs(fill_glyph, stdout);
++ for (int i = 0; i < empty; i++) fputs(empty_glyph, stdout);
++ }
+ printf("] %3ld%% %s", pct, status);
+ fflush(stdout);
+ }
+@@ -226,6 +255,740 @@
+ fflush(stdout);
+ }
+
++/* ===== alert loop =====
++ * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap.
++ */
++
++static int
++is_foreground(void)
++{
++ if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) return 0;
++ pid_t pgrp = tcgetpgrp(STDIN_FILENO);
++ if (pgrp < 0) return 0;
++ return pgrp == getpgrp();
++}
++
++static void
++alert_loop(int flash)
++{
++ fputs("\033[?25h\033[?7h", stdout);
++ int ticks = 0;
++ while (!interrupted) {
++ fputc('\a', stdout);
++ if (flash) fputs("\033[?5h", stdout);
++ fflush(stdout);
++ sleep_ms(150);
++ if (flash) fputs("\033[?5l", stdout);
++ fflush(stdout);
++
++ int acked = 0;
++ for (int i = 0; i < 15 && !interrupted; i++) {
++ if (ttyfd < 0) { sleep_ms(100); continue; }
++ struct pollfd pfd = { .fd = ttyfd, .events = POLLIN };
++ int pr = poll(&pfd, 1, 100);
++ if (pr > 0 && (pfd.revents & POLLIN)) {
++ char ch;
++ ssize_t rd = read(ttyfd, &ch, 1);
++ if (rd >= 0) { acked = 1; break; }
++ } else if (pr < 0) {
++ sleep_ms(100);
++ }
++ }
++ if (acked) break;
++ if (++ticks > 160) break;
++ }
++ fflush(stdout);
++}
++
++/* ===== subprocess + placeholder expansion =====
++ * Tokens split on whitespace; {title} and {msg} expanded within each token.
++ * No shell involved — argv stays argv.
++ */
++
++static int
++build_argv(const char *cmd, const Config *c, char ***out)
++{
++ char buf[2048];
++ snprintf(buf, sizeof(buf), "%s", cmd);
++ char **argv = calloc(64, sizeof(char *));
++ if (!argv) return -1;
++ int argc = 0;
++ char *p = buf;
++ while (*p && argc < 63) {
++ while (*p && isspace((unsigned char)*p)) p++;
++ if (!*p) break;
++ char *start = p;
++ while (*p && !isspace((unsigned char)*p)) p++;
++ if (*p) *p++ = '\0';
++ char tok[2048]; size_t oi = 0;
++ for (char *q = start; *q && oi < sizeof(tok) - 1; ) {
++ const char *sub = NULL; size_t skip = 0;
++ if (!strncmp(q, "{msg}", 5)) { sub = c->msg; skip = 5; }
++ else if (!strncmp(q, "{title}", 7)) { sub = c->title; skip = 7; }
++ if (sub) {
++ size_t sl = strlen(sub);
++ if (oi + sl >= sizeof(tok)) sl = sizeof(tok) - 1 - oi;
++ memcpy(tok + oi, sub, sl);
++ oi += sl; q += skip;
++ } else { tok[oi++] = *q++; }
++ }
++ tok[oi] = '\0';
++ argv[argc++] = strdup(tok);
++ }
++ argv[argc] = NULL;
++ *out = argv;
++ return argc;
++}
++
++static void
++free_argv(char **argv)
++{
++ if (!argv) return;
++ for (int i = 0; argv[i]; i++) free(argv[i]);
++ free(argv);
++}
++
++static void
++spawn_bg(char *const argv[])
++{
++ pid_t pid = fork();
++ if (pid == 0) {
++ setsid();
++ int devnull = open("/dev/null", O_RDWR);
++ if (devnull >= 0) {
++ dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2);
++ if (devnull > 2) close(devnull);
++ }
++ execvp(argv[0], argv);
++ _exit(127);
++ }
++ (void)pid;
++}
++
++static int
++command_exists(const char *name)
++{
++ const char *path = getenv("PATH");
++ if (!path) return 0;
++ char buf[512];
++ const char *p = path;
++ while (*p) {
++ const char *e = strchr(p, ':');
++ size_t len = e ? (size_t)(e - p) : strlen(p);
++ if (len > 0 && len + strlen(name) + 2 < sizeof(buf)) {
++ snprintf(buf, sizeof(buf), "%.*s/%s", (int)len, p, name);
++ if (access(buf, X_OK) == 0) return 1;
++ }
++ if (!e) break;
++ p = e + 1;
++ }
++ return 0;
++}
++
++static const char *
++detect_sound_player(void)
++{
++ static const char *cands[] = { "paplay", "aplay", "mpv", "ffplay", NULL };
++ for (int i = 0; cands[i]; i++)
++ if (command_exists(cands[i])) return cands[i];
++ return NULL;
++}
++
++static const char *
++default_sound_path(void)
++{
++ static const char *paths[] = {
++ "/usr/share/sounds/freedesktop/stereo/complete.oga",
++ "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga",
++ "/usr/share/sounds/alsa/Front_Center.wav",
++ NULL
++ };
++ for (int i = 0; paths[i]; i++)
++ if (access(paths[i], R_OK) == 0) return paths[i];
++ return NULL;
++}
++
++static void
++notify_and_sound(const Config *c)
++{
++ if (c->notify_cmd[0] && command_exists("notify-send")) {
++ char **a;
++ if (build_argv(c->notify_cmd, c, &a) > 0) {
++ spawn_bg(a);
++ free_argv(a);
++ }
++ }
++ if (!c->quiet) {
++ if (c->sound_cmd[0]) {
++ char **a;
++ if (build_argv(c->sound_cmd, c, &a) > 0) {
++ spawn_bg(a);
++ free_argv(a);
++ }
++ } else {
++ const char *pl = detect_sound_player();
++ const char *sp = default_sound_path();
++ if (pl && sp) {
++ char *a[] = { (char *)pl, (char *)sp, NULL };
++ spawn_bg(a);
++ }
++ }
++ }
++}
++
++/* ===== log (TSV: ts<TAB>seconds<TAB>comment) ===== */
++
++static int
++build_config_path(char *buf, size_t bufsz, const char *suffix)
++{
++ const char *home = getenv("HOME");
++ const char *xdg = getenv("XDG_CONFIG_HOME");
++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/%s", xdg, suffix);
++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/%s", home, suffix);
++ else return -1;
++ return 0;
++}
++
++static void
++migrate_legacy_dir(void)
++{
++ const char *home = getenv("HOME");
++ const char *xdg = getenv("XDG_CONFIG_HOME");
++ char base[512];
++ if (xdg && *xdg) snprintf(base, sizeof(base), "%s", xdg);
++ else if (home && *home) snprintf(base, sizeof(base), "%s/.config", home);
++ else return;
++ char old_dir[600], new_dir[600];
++ snprintf(old_dir, sizeof(old_dir), "%s/timer", base);
++ snprintf(new_dir, sizeof(new_dir), "%s/ttym", base);
++ struct stat sb;
++ if (stat(new_dir, &sb) == 0) return;
++ if (stat(old_dir, &sb) != 0 || !S_ISDIR(sb.st_mode)) return;
++ if (rename(old_dir, new_dir) != 0) return;
++ char old_log[700], new_log[700];
++ snprintf(old_log, sizeof(old_log), "%s/timer.log", new_dir);
++ snprintf(new_log, sizeof(new_log), "%s/ttym.log", new_dir);
++ if (stat(old_log, &sb) == 0) rename(old_log, new_log);
++}
++
++static int
++mkdir_p(const char *path, mode_t mode)
++{
++ char buf[1024];
++ snprintf(buf, sizeof(buf), "%s", path);
++ for (char *p = buf + 1; *p; p++) {
++ if (*p == '/') {
++ *p = '\0';
++ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1;
++ *p = '/';
++ }
++ }
++ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1;
++ return 0;
++}
++
++static void
++iso_local(char *buf, size_t bufsz)
++{
++ time_t t = time(NULL);
++ struct tm tm;
++ localtime_r(&t, &tm);
++ strftime(buf, bufsz, "%Y-%m-%d %H:%M:%S", &tm);
++}
++
++static void
++sanitize_field(char *s)
++{
++ for (; *s; s++)
++ if (*s == '\t' || *s == '\n' || *s == '\r') *s = ' ';
++}
++
++static int
++is_old_format(const char *line)
++{
++ return strlen(line) >= 11 && line[10] == 'T';
++}
++
++static void
++migrate_line(const char *line, char *out, size_t outsz)
++{
++ if (strlen(line) < 19) { snprintf(out, outsz, "%s", line); return; }
++ char date[11], tpart[9];
++ memcpy(date, line, 10); date[10] = '\0';
++ memcpy(tpart, line + 11, 8); tpart[8] = '\0';
++ const char *tab = strchr(line, '\t');
++ if (!tab) { snprintf(out, outsz, "%s %s\n", date, tpart); return; }
++ snprintf(out, outsz, "%s %s%s", date, tpart, tab);
++}
++
++static void
++migrate_log_if_needed(const char *path)
++{
++ FILE *f = fopen(path, "r");
++ if (!f) return;
++ char probe[2048];
++ int needs = 0;
++ while (fgets(probe, sizeof(probe), f))
++ if (is_old_format(probe)) { needs = 1; break; }
++ if (!needs) { fclose(f); return; }
++ rewind(f);
++ char tmp[1024];
++ snprintf(tmp, sizeof(tmp), "%s.tmp", path);
++ FILE *out = fopen(tmp, "w");
++ if (!out) { fclose(f); return; }
++ char line[2048];
++ while (fgets(line, sizeof(line), f)) {
++ if (is_old_format(line)) {
++ char conv[2048];
++ migrate_line(line, conv, sizeof(conv));
++ fputs(conv, out);
++ } else fputs(line, out);
++ }
++ fclose(f);
++ fclose(out);
++ rename(tmp, path);
++}
++
++static void
++write_log(long duration_s, const char *comment)
++{
++ char path[512];
++ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return;
++ char dir[512];
++ snprintf(dir, sizeof(dir), "%s", path);
++ char *slash = strrchr(dir, '/');
++ if (slash) { *slash = '\0'; mkdir_p(dir, 0700); }
++ migrate_log_if_needed(path);
++ int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600);
++ if (fd < 0) return;
++ char ts[32]; iso_local(ts, sizeof(ts));
++ char safe[512]; snprintf(safe, sizeof(safe), "%s", comment ? comment : "");
++ sanitize_field(safe);
++ char line[1024];
++ int n = snprintf(line, sizeof(line), "%s\t%ld\t%s\n", ts, duration_s, safe);
++ if (n > 0) { ssize_t w = write(fd, line, (size_t)n); (void)w; }
++ close(fd);
++}
++
++static int
++read_log_lines(char ***out_lines, size_t *out_n)
++{
++ char path[512];
++ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return -1;
++ migrate_log_if_needed(path);
++ FILE *f = fopen(path, "r");
++ if (!f) { *out_lines = NULL; *out_n = 0; return 0; }
++ size_t cap = 64, n = 0;
++ char **lines = malloc(cap * sizeof(char *));
++ if (!lines) { fclose(f); return -1; }
++ char buf[2048];
++ while (fgets(buf, sizeof(buf), f)) {
++ size_t l = strlen(buf);
++ if (l && buf[l-1] == '\n') buf[l-1] = '\0';
++ if (!*buf) continue;
++ if (n >= cap) {
++ cap *= 2;
++ char **nl = realloc(lines, cap * sizeof(char *));
++ if (!nl) break;
++ lines = nl;
++ }
++ lines[n++] = strdup(buf);
++ }
++ fclose(f);
++ *out_lines = lines;
++ *out_n = n;
++ return 0;
++}
++
++static void
++free_lines(char **lines, size_t n)
++{
++ if (!lines) return;
++ for (size_t i = 0; i < n; i++) free(lines[i]);
++ free(lines);
++}
++
++static int
++split_log_line(char *line, char **ts, long *secs, char **comment)
++{
++ char *t1 = strchr(line, '\t'); if (!t1) return -1;
++ *t1 = '\0';
++ char *t2 = strchr(t1 + 1, '\t'); if (!t2) return -1;
++ *t2 = '\0';
++ char *end;
++ long v = strtol(t1 + 1, &end, 10);
++ if (end == t1 + 1) return -1;
++ *ts = line; *secs = v; *comment = t2 + 1;
++ return 0;
++}
++
++static void
++fmt_long(long s, char *buf, size_t bufsz)
++{
++ if (s < 0) s = 0;
++ long d = s / 86400; s %= 86400;
++ long h = s / 3600; s %= 3600;
++ long m = s / 60;
++ long sec = s % 60;
++ snprintf(buf, bufsz, "%02ld %02ld:%02ld:%02ld", d, h, m, sec);
++}
++
++static int
++cmd_read(int argc, char **argv)
++{
++ int n_tail = 0, raw = 0;
++ for (int i = 0; i < argc; i++) {
++ if (!strcmp(argv[i], "-n") && i + 1 < argc) {
++ n_tail = atoi(argv[++i]);
++ if (n_tail < 0) n_tail = 0;
++ } else if (!strcmp(argv[i], "--raw")) {
++ raw = 1;
++ } else {
++ fprintf(stderr, "timer -r: unknown arg: %s\n", argv[i]);
++ return 1;
++ }
++ }
++ char **lines = NULL; size_t n = 0;
++ if (read_log_lines(&lines, &n) < 0) return 1;
++ if (n == 0) { free_lines(lines, n); return 0; }
++ size_t start = 0;
++ if (n_tail > 0 && (size_t)n_tail < n) start = n - n_tail;
++ if (raw) {
++ for (size_t i = start; i < n; i++) puts(lines[i]);
++ } else {
++ printf("%-19s %-11s %s\n", "TIMESTAMP", "DURATION", "COMMENT");
++ for (size_t i = start; i < n; i++) {
++ char *copy = strdup(lines[i]);
++ char *ts, *cmt; long secs;
++ if (split_log_line(copy, &ts, &secs, &cmt) == 0) {
++ char dur[32];
++ fmt_long(secs, dur, sizeof(dur));
++ printf("%-19s %-11s %s\n", ts, dur, cmt);
++ } else {
++ puts(lines[i]);
++ }
++ free(copy);
++ }
++ }
++ free_lines(lines, n);
++ return 0;
++}
++
++static int
++parse_log_ts(const char *ts, struct tm *out)
++{
++ memset(out, 0, sizeof(*out));
++ if (sscanf(ts, "%d-%d-%d %d:%d:%d",
++ &out->tm_year, &out->tm_mon, &out->tm_mday,
++ &out->tm_hour, &out->tm_min, &out->tm_sec) != 6) return -1;
++ out->tm_year -= 1900;
++ out->tm_mon -= 1;
++ out->tm_isdst = -1;
++ return 0;
++}
++
++typedef struct TagAcc {
++ char tag[128];
++ long secs;
++ long count;
++ struct TagAcc *next;
++} TagAcc;
++
++static void
++tag_add(TagAcc **head, const char *tag, long secs)
++{
++ for (TagAcc *a = *head; a; a = a->next)
++ if (!strcmp(a->tag, tag)) { a->secs += secs; a->count++; return; }
++ TagAcc *n = calloc(1, sizeof(*n));
++ if (!n) return;
++ snprintf(n->tag, sizeof(n->tag), "%s", tag);
++ n->secs = secs;
++ n->count = 1;
++ n->next = *head;
++ *head = n;
++}
++
++static void
++extract_tags(const char *comment, long secs, TagAcc **head)
++{
++ const char *p = comment;
++ int found = 0;
++ while (*p) {
++ int at_boundary = (p == comment) || isspace((unsigned char)*(p-1));
++ if (*p == '+' && at_boundary) {
++ const char *s = p + 1, *e = s;
++ while (*e && !isspace((unsigned char)*e)) e++;
++ if (e > s) {
++ char buf[128];
++ size_t len = (size_t)(e - s);
++ if (len >= sizeof(buf)) len = sizeof(buf) - 1;
++ memcpy(buf, s, len); buf[len] = '\0';
++ tag_add(head, buf, secs);
++ found = 1;
++ }
++ p = e;
++ } else p++;
++ }
++ if (!found) tag_add(head, "(untagged)", secs);
++}
++
++static int
++cmd_stats(int argc, char **argv)
++{
++ const char *period = (argc > 0) ? argv[0] : "today";
++ time_t now = time(NULL);
++ struct tm now_tm; localtime_r(&now, &now_tm);
++ time_t cutoff = 0;
++ if (!strcmp(period, "all")) cutoff = 0;
++ else if (!strcmp(period, "today")) {
++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0;
++ cutoff = mktime(&t);
++ } else if (!strcmp(period, "week")) {
++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0;
++ int back = (t.tm_wday == 0) ? 6 : t.tm_wday - 1;
++ t.tm_mday -= back;
++ cutoff = mktime(&t);
++ } else if (!strcmp(period, "month")) {
++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; t.tm_mday = 1;
++ cutoff = mktime(&t);
++ } else {
++ fprintf(stderr, "stats: unknown period '%s' (today|week|month|all)\n", period);
++ return 1;
++ }
++ char **lines = NULL; size_t n = 0;
++ if (read_log_lines(&lines, &n) < 0) return 1;
++ long total = 0, count = 0;
++ TagAcc *tags = NULL;
++ for (size_t i = 0; i < n; i++) {
++ char *copy = strdup(lines[i]);
++ char *ts, *cmt; long secs;
++ if (split_log_line(copy, &ts, &secs, &cmt) != 0) { free(copy); continue; }
++ struct tm tm;
++ if (parse_log_ts(ts, &tm) != 0) { free(copy); continue; }
++ time_t entry = mktime(&tm);
++ if (cutoff && entry < cutoff) { free(copy); continue; }
++ total += secs; count++;
++ extract_tags(cmt, secs, &tags);
++ free(copy);
++ }
++ free_lines(lines, n);
++ char tot[32]; fmt_long(total, tot, sizeof(tot));
++ printf("\033[1m== timer stats: %s ==\033[0m\n", period);
++ printf("entries: %ld total: %s\n\n", count, tot);
++ int tag_n = 0;
++ for (TagAcc *a = tags; a; a = a->next) tag_n++;
++ if (tag_n > 0) {
++ TagAcc **arr = malloc(tag_n * sizeof(*arr));
++ int i = 0;
++ for (TagAcc *a = tags; a; a = a->next) arr[i++] = a;
++ for (int x = 0; x < tag_n - 1; x++)
++ for (int y = x + 1; y < tag_n; y++)
++ if (arr[y]->secs > arr[x]->secs) {
++ TagAcc *t = arr[x]; arr[x] = arr[y]; arr[y] = t;
++ }
++ printf("%-24s %-11s %5s\n", "TAG", "DURATION", "COUNT");
++ for (int z = 0; z < tag_n; z++) {
++ char d[32]; fmt_long(arr[z]->secs, d, sizeof(d));
++ printf("%-24s %-11s %5ld\n", arr[z]->tag, d, arr[z]->count);
++ }
++ free(arr);
++ }
++ while (tags) { TagAcc *next = tags->next; free(tags); tags = next; }
++ return 0;
++}
++
++/* ===== hooks ===== */
++
++static int
++build_hook_path(char *buf, size_t bufsz, const char *name)
++{
++ const char *home = getenv("HOME");
++ const char *xdg = getenv("XDG_CONFIG_HOME");
++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/hooks/%s", xdg, name);
++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/hooks/%s", home, name);
++ else return -1;
++ return 0;
++}
++
++static void
++run_hook(const char *name, const char *mode, long duration_s, const char *comment)
++{
++ char path[512];
++ if (build_hook_path(path, sizeof(path), name) < 0) return;
++ if (access(path, X_OK) != 0) return;
++ char dur[32];
++ snprintf(dur, sizeof(dur), "%ld", duration_s);
++ pid_t pid = fork();
++ if (pid == 0) {
++ int devnull = open("/dev/null", O_RDWR);
++ if (devnull >= 0) {
++ dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2);
++ if (devnull > 2) close(devnull);
++ }
++ char *argv[] = { path, (char *)mode, dur,
++ (char *)(comment ? comment : ""), NULL };
++ execvp(argv[0], argv);
++ _exit(127);
++ } else if (pid > 0) {
++ waitpid(pid, NULL, 0);
++ }
++}
++
++/* ===== runtime config file (~/.config/ttym/config) =====
++ * key = value lines; '#' comments. Unknown keys ignored.
++ */
++
++static void
++trim(char *s)
++{
++ char *p = s;
++ while (*p && isspace((unsigned char)*p)) p++;
++ if (p != s) memmove(s, p, strlen(p) + 1);
++ size_t n = strlen(s);
++ while (n > 0 && isspace((unsigned char)s[n-1])) s[--n] = '\0';
++}
++
++static void
++ensure_default_config_file(const char *path)
++{
++ if (access(path, F_OK) == 0) return;
++ char dir[512];
++ snprintf(dir, sizeof(dir), "%s", path);
++ char *slash = strrchr(dir, '/');
++ if (slash) { *slash = '\0'; mkdir_p(dir, 0700); }
++ FILE *f = fopen(path, "w");
++ if (!f) return;
++ fputs(
++"# ttym configuration file\n"
++"#\n"
++"# Location : ~/.config/ttym/config (/etc/ttym.conf is read first, if present)\n"
++"# Syntax : key = value -- one entry per line; '#' begins a comment.\n"
++"# A commented-out or absent key keeps its built-in default.\n"
++"# Command-line flags override anything set in this file.\n"
++"#\n"
++"# Every key below is recognised by this build. Uncomment and edit to taste.\n"
++"# ---------------------------------------------------------------------------\n"
++"\n"
++"# Progress-bar glyphs. Set BOTH or NEITHER -- mismatched display widths\n"
++"# break bar alignment. Multi-byte UTF-8 is fine. These override bar_style.\n"
++"# bar_fill = >\n"
++"# bar_empty = -\n"
++"\n"
++"# Named progress-bar style. One of:\n"
++"# unicode ascii hash dots line block arrow minimal\n"
++"# Leave unset to auto-detect (unicode on a UTF-8 locale, otherwise ascii).\n"
++"# bar_style = unicode\n"
++"\n"
++"# Reverse-video flash during the completion alert. on | off\n"
++"# flash = on\n"
++"\n"
++"# Persistent alert: keep ringing the bell until a key is pressed\n"
++"# (foreground runs only). on | off\n"
++"# alert_persist = on\n"
++"\n"
++"# Desktop-notification title.\n"
++"# title = Timer\n"
++"\n"
++"# Desktop-notification command run when a countdown finishes. Tokens are\n"
++"# split on whitespace; {title}/{msg} placeholders expand. Empty disables.\n"
++"# notify_cmd = notify-send -u critical {title} {msg}\n"
++"\n"
++"# Sound played when a countdown finishes. Leave unset to auto-detect a\n"
++"# player (paplay/aplay/mpv/ffplay) and play a default system sound.\n"
++"# Tokens are split on whitespace; {title}/{msg} placeholders expand.\n"
++"# sound_cmd = paplay /usr/share/sounds/freedesktop/stereo/complete.oga\n",
++ f);
++ fclose(f);
++ chmod(path, 0600);
++}
++
++static void
++load_config_file(Config *c, const char *path)
++{
++ FILE *f = fopen(path, "r");
++ if (!f) return;
++ char line[1024];
++ while (fgets(line, sizeof(line), f)) {
++ char *hash = strchr(line, '#');
++ if (hash) *hash = '\0';
++ char *eq = strchr(line, '=');
++ if (!eq) continue;
++ *eq = '\0';
++ char *k = line, *v = eq + 1;
++ trim(k); trim(v);
++ if (!*k) continue;
++ if (!strcmp(k, "bar_fill")) snprintf(c->bar_fill, sizeof(c->bar_fill), "%s", v);
++ else if (!strcmp(k, "bar_empty")) snprintf(c->bar_empty, sizeof(c->bar_empty), "%s", v);
++ else if (!strcmp(k, "bar_style")) snprintf(c->bar_style, sizeof(c->bar_style), "%s", v);
++ else if (!strcmp(k, "flash")) c->flash = parse_bool_str(v);
++ else if (!strcmp(k, "alert_persist")) c->alert_persist = parse_bool_str(v);
++ else if (!strcmp(k, "title")) snprintf(c->title, sizeof(c->title), "%s", v);
++ else if (!strcmp(k, "notify_cmd")) snprintf(c->notify_cmd, sizeof(c->notify_cmd), "%s", v);
++ else if (!strcmp(k, "sound_cmd")) snprintf(c->sound_cmd, sizeof(c->sound_cmd), "%s", v);
++ }
++ fclose(f);
++}
++
++static void
++load_config(Config *c)
++{
++ load_config_file(c, "/etc/ttym.conf");
++ char path[512];
++ if (build_config_path(path, sizeof(path), "config") == 0) {
++ ensure_default_config_file(path);
++ load_config_file(c, path);
++ }
++}
++
++/* ===== bar styles ===== */
++
++typedef struct { const char *name, *fill, *empty; } BarStyle;
++
++static const BarStyle bar_styles[] = {
++ { "unicode", "\xe2\x96\x88", "\xe2\x96\x91" }, /* U+2588 U+2591 */
++ { "ascii", "=", "-" },
++ { "hash", "#", "." },
++ { "dots", "\xe2\x80\xa2", "\xc2\xb7" }, /* U+2022 U+00B7 */
++ { "line", "\xe2\x94\x81", "\xe2\x94\x80" }, /* U+2501 U+2500 */
++ { "block", "\xe2\x96\x93", "\xe2\x96\x91" }, /* U+2593 U+2591 */
++ { "arrow", "\xe2\x96\xb6", "\xe2\x96\xb7" }, /* U+25B6 U+25B7 */
++ { "minimal", "", "" },
++ { NULL, NULL, NULL }
++};
++
++static const BarStyle *
++find_bar_style(const char *name)
++{
++ if (!name || !*name) return NULL;
++ for (int i = 0; bar_styles[i].name; i++)
++ if (!strcmp(bar_styles[i].name, name)) return &bar_styles[i];
++ return NULL;
++}
++
++static int
++is_utf8(void)
++{
++ const char *l = setlocale(LC_CTYPE, "");
++ if (!l) return 0;
++ return strstr(l, "UTF-8") || strstr(l, "utf8") || strstr(l, "utf-8");
++}
++
++static void
++resolve_bar(const Config *c, const char **fill, const char **empty, int *minimal)
++{
++ *minimal = 0;
++ const char *name = (c->bar_style[0]) ? c->bar_style : BAR_STYLE;
++ const BarStyle *s = find_bar_style(name);
++ if (!s) s = find_bar_style(is_utf8() ? "unicode" : "ascii");
++ if (s && !strcmp(s->name, "minimal")) {
++ *minimal = 1;
++ *fill = " "; *empty = " ";
++ return;
++ }
++ *fill = s ? s->fill : BAR_FILL;
++ *empty = s ? s->empty : BAR_EMPTY;
++}
++
+ /* ===== usage =====
+ * Patches MUST update this in lockstep with new flags so -h always
+ * reflects the build.
+@@ -236,17 +999,25 @@
+ {
+ fprintf(f,
+ "usage:\n"
+-" timer [FLAGS] DURATION countdown\n"
+-" timer [FLAGS] stopwatch\n"
++" timer [FLAGS] DURATION [MESSAGE...] countdown\n"
++" timer [FLAGS] stopwatch\n"
++" timer -r [-n N] [--raw] read log\n"
++" timer --stats [today|week|month|all] stats (default: today)\n"
+ "\n"
+ "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n"
+ "FLAGS:\n"
++" -c TEXT comment for log (+tag tokens are extracted)\n"
+ " -q no sound\n"
+ " -s silent\n"
++" --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal\n"
++" --no-persist skip alert-until-keypress loop\n"
++" --flash on|off terminal flash during alert loop\n"
+ " -- end of options\n"
+ " -h help\n"
+ "\n"
+-"controls: [space] pause/resume [q | Ctrl+C] quit\n");
++"controls: [space] pause/resume [q | Ctrl+C] quit\n"
++"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n"
++"config: ~/.config/ttym/config (auto-generated on first run)\n");
+ }
+
+ /* ===== run loop ===== */
+@@ -259,6 +1030,14 @@
+ int barw = cols - 40;
+ if (barw < 10) barw = 10;
+
++ const char *fill_glyph, *empty_glyph;
++ int minimal = 0;
++ resolve_bar(cfg, &fill_glyph, &empty_glyph, &minimal);
++ /* explicit per-glyph overrides from the runtime config file
++ * (bar_fill / bar_empty) win over the named --bar style. */
++ if (cfg->bar_fill[0]) fill_glyph = cfg->bar_fill;
++ if (cfg->bar_empty[0]) empty_glyph = cfg->bar_empty;
++
+ ttyfd = open("/dev/tty", O_RDONLY);
+ if (ttyfd >= 0 && isatty(ttyfd)) {
+ if (tcgetattr(ttyfd, &oldtios) == 0) {
+@@ -280,6 +1059,9 @@
+ if (out_tty)
+ fputs("\033[?25l\033[?7l", stdout);
+
++ run_hook("on_start", stopwatch ? "stopwatch" : "countdown",
++ stopwatch ? 0 : total, cfg->comment);
++
+ long start_ms = now_ms();
+ long pause_accum = 0;
+ long pause_start = 0;
+@@ -300,7 +1082,8 @@
+ draw_stopwatch(elapsed_ms, paused);
+ } else {
+ if (out_tty)
+- draw_countdown(total, elapsed_ms, paused, barw);
++ draw_countdown(total, elapsed_ms, paused, barw,
++ fill_glyph, empty_glyph, minimal);
+ if (!paused && elapsed_ms >= total * 1000L) break;
+ }
+
+@@ -356,6 +1139,7 @@
+ printf("\r\033[32mSTOP %s%*s\033[0m\n", el, 30, "");
+ else
+ printf("STOP %s\n", el);
++ run_hook("on_quit", "stopwatch", elapsed_s, cfg->comment);
+ } else if (user_quit) {
+ char el[24];
+ fmt_clock(elapsed_s, el, sizeof(el));
+@@ -363,6 +1147,7 @@
+ printf("\r\033[33mQUIT at %s%*s\033[0m\n", el, 30, "");
+ else
+ printf("QUIT at %s\n", el);
++ run_hook("on_quit", "countdown", elapsed_s, cfg->comment);
+ } else {
+ char tb[24];
+ fmt_clock(total, tb, sizeof(tb));
+@@ -370,14 +1155,21 @@
+ printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, "");
+ else
+ printf("DONE %s 100%%\n", tb);
++ run_hook("on_done", "countdown", total, cfg->comment);
++ if (!cfg->silent) notify_and_sound(cfg);
+ if (!cfg->silent && !cfg->quiet && out_tty) {
+ fputc('\a', stdout);
+ fflush(stdout);
+ sleep_ms(50);
+ }
++ if (!cfg->silent && cfg->alert_persist && is_foreground()) {
++ interrupted = 0;
++ alert_loop(cfg->flash);
++ }
+ }
+
+ (void)cfg;
++ write_log(elapsed_s, cfg->comment);
+ return user_quit ? 130 : 0;
+ }
+
+@@ -386,7 +1178,21 @@
+ int
+ main(int argc, char **argv)
+ {
++ migrate_legacy_dir();
++
++ if (argc >= 2 && !strcmp(argv[1], "-r"))
++ return cmd_read(argc - 2, argv + 2);
++ if (argc >= 2 && !strcmp(argv[1], "--stats"))
++ return cmd_stats(argc - 2, argv + 2);
++
+ Config cfg = {0};
++ cfg.alert_persist = ALERT_PERSIST;
++ cfg.flash = FLASH;
++ snprintf(cfg.title, sizeof(cfg.title), "%s", TITLE ? TITLE : "");
++ snprintf(cfg.notify_cmd, sizeof(cfg.notify_cmd), "%s", NOTIFY_CMD ? NOTIFY_CMD : "");
++ snprintf(cfg.sound_cmd, sizeof(cfg.sound_cmd), "%s", SOUND_CMD ? SOUND_CMD : "");
++ snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up");
++ load_config(&cfg);
+
+ /* Pre-strip long-form flags before getopt. Stop at "--". */
+ int new_argc = 0;
+@@ -399,15 +1205,32 @@
+ new_argv[new_argc++] = argv[i];
+ continue;
+ }
++ if (!past_dd && !strcmp(argv[i], "--no-persist")) {
++ cfg.alert_persist = 0;
++ continue;
++ }
++ if (!past_dd && !strncmp(argv[i], "--flash=", 8)) {
++ cfg.flash = parse_bool_str(argv[i] + 8);
++ continue;
++ }
++ if (!past_dd && !strcmp(argv[i], "--flash") && i + 1 < argc) {
++ cfg.flash = parse_bool_str(argv[++i]);
++ continue;
++ }
++ if (!past_dd && !strcmp(argv[i], "--bar") && i + 1 < argc) {
++ snprintf(cfg.bar_style, sizeof(cfg.bar_style), "%s", argv[++i]);
++ continue;
++ }
+ new_argv[new_argc++] = argv[i];
+ }
+ new_argv[new_argc] = NULL;
+
+ int opt;
+- while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) {
++ while ((opt = getopt(new_argc, new_argv, "qsc:h")) != -1) {
+ switch (opt) {
+ case 'q': cfg.quiet = 1; break;
+ case 's': cfg.silent = 1; break;
++ case 'c': snprintf(cfg.comment, sizeof(cfg.comment), "%s", optarg); break;
+ case 'h': usage(stdout); free(new_argv); return 0;
+ default: usage(stderr); free(new_argv); return 1;
+ }
+@@ -424,6 +1247,16 @@
+ free(new_argv);
+ return 1;
+ }
++ if (rem_argc > 1) {
++ size_t off = 0;
++ cfg.msg[0] = '\0';
++ for (int i = 1; i < rem_argc && off < sizeof(cfg.msg) - 2; i++) {
++ int n = snprintf(cfg.msg + off, sizeof(cfg.msg) - off,
++ "%s%s", (i > 1 ? " " : ""), rem_argv[i]);
++ if (n < 0) break;
++ off += (size_t)n;
++ }
++ }
+ }
+
+ int rc = run(total, stopwatch, &cfg);
diff --git a/patches/ttym-hooks-2.0.diff b/patches/ttym-hooks-2.0.diff
new file mode 100644
index 0000000..dbb4f6a
--- /dev/null
+++ b/patches/ttym-hooks-2.0.diff
@@ -0,0 +1,127 @@
+diff -ruN a/ttym.c b/ttym.c
+--- a/ttym.c 2026-05-20 12:02:00.123998202 +0200
++++ b/ttym.c 2026-05-20 12:24:33.731972801 +0200
+@@ -12,6 +12,8 @@
+ #include <stdlib.h>
+ #include <string.h>
+ #include <sys/ioctl.h>
++#include <sys/stat.h>
++#include <sys/wait.h>
+ #include <termios.h>
+ #include <time.h>
+ #include <unistd.h>
+@@ -23,6 +25,7 @@
+ typedef struct {
+ int quiet; /* -q: suppress completion bell */
+ int silent; /* -s: no bell, no extras */
++ char comment[512]; /* passed as 3rd arg to hooks */
+ } Config;
+
+ static struct termios oldtios;
+@@ -226,6 +229,43 @@
+ fflush(stdout);
+ }
+
++/* ===== hooks ===== */
++
++static int
++build_hook_path(char *buf, size_t bufsz, const char *name)
++{
++ const char *home = getenv("HOME");
++ const char *xdg = getenv("XDG_CONFIG_HOME");
++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/hooks/%s", xdg, name);
++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/hooks/%s", home, name);
++ else return -1;
++ return 0;
++}
++
++static void
++run_hook(const char *name, const char *mode, long duration_s, const char *comment)
++{
++ char path[512];
++ if (build_hook_path(path, sizeof(path), name) < 0) return;
++ if (access(path, X_OK) != 0) return;
++ char dur[32];
++ snprintf(dur, sizeof(dur), "%ld", duration_s);
++ pid_t pid = fork();
++ if (pid == 0) {
++ int devnull = open("/dev/null", O_RDWR);
++ if (devnull >= 0) {
++ dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2);
++ if (devnull > 2) close(devnull);
++ }
++ char *argv[] = { path, (char *)mode, dur,
++ (char *)(comment ? comment : ""), NULL };
++ execvp(argv[0], argv);
++ _exit(127);
++ } else if (pid > 0) {
++ waitpid(pid, NULL, 0);
++ }
++}
++
+ /* ===== usage =====
+ * Patches MUST update this in lockstep with new flags so -h always
+ * reflects the build.
+@@ -241,12 +281,14 @@
+ "\n"
+ "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n"
+ "FLAGS:\n"
++" -c TEXT comment, passed as 3rd arg to hooks\n"
+ " -q no sound\n"
+ " -s silent\n"
+ " -- end of options\n"
+ " -h help\n"
+ "\n"
+-"controls: [space] pause/resume [q | Ctrl+C] quit\n");
++"controls: [space] pause/resume [q | Ctrl+C] quit\n"
++"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n");
+ }
+
+ /* ===== run loop ===== */
+@@ -280,6 +322,9 @@
+ if (out_tty)
+ fputs("\033[?25l\033[?7l", stdout);
+
++ run_hook("on_start", stopwatch ? "stopwatch" : "countdown",
++ stopwatch ? 0 : total, cfg->comment);
++
+ long start_ms = now_ms();
+ long pause_accum = 0;
+ long pause_start = 0;
+@@ -356,6 +401,7 @@
+ printf("\r\033[32mSTOP %s%*s\033[0m\n", el, 30, "");
+ else
+ printf("STOP %s\n", el);
++ run_hook("on_quit", "stopwatch", elapsed_s, cfg->comment);
+ } else if (user_quit) {
+ char el[24];
+ fmt_clock(elapsed_s, el, sizeof(el));
+@@ -363,6 +409,7 @@
+ printf("\r\033[33mQUIT at %s%*s\033[0m\n", el, 30, "");
+ else
+ printf("QUIT at %s\n", el);
++ run_hook("on_quit", "countdown", elapsed_s, cfg->comment);
+ } else {
+ char tb[24];
+ fmt_clock(total, tb, sizeof(tb));
+@@ -370,6 +417,7 @@
+ printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, "");
+ else
+ printf("DONE %s 100%%\n", tb);
++ run_hook("on_done", "countdown", total, cfg->comment);
+ if (!cfg->silent && !cfg->quiet && out_tty) {
+ fputc('\a', stdout);
+ fflush(stdout);
+@@ -404,10 +452,11 @@
+ new_argv[new_argc] = NULL;
+
+ int opt;
+- while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) {
++ while ((opt = getopt(new_argc, new_argv, "qsc:h")) != -1) {
+ switch (opt) {
+ case 'q': cfg.quiet = 1; break;
+ case 's': cfg.silent = 1; break;
++ case 'c': snprintf(cfg.comment, sizeof(cfg.comment), "%s", optarg); break;
+ case 'h': usage(stdout); free(new_argv); return 0;
+ default: usage(stderr); free(new_argv); return 1;
+ }
diff --git a/patches/ttym-logging-2.0.diff b/patches/ttym-logging-2.0.diff
new file mode 100644
index 0000000..d654d50
--- /dev/null
+++ b/patches/ttym-logging-2.0.diff
@@ -0,0 +1,447 @@
+diff -ruN a/config.def.h b/config.def.h
+--- a/config.def.h 2026-05-20 11:57:26.722347891 +0200
++++ b/config.def.h 2026-05-20 11:57:26.726347974 +0200
+@@ -3,3 +3,8 @@
+ /* progress bar glyphs (ASCII default) */
+ static const char *const BAR_FILL = ">";
+ static const char *const BAR_EMPTY = "-";
++
++/* log location is ~/.config/ttym/ttym.log (honours $XDG_CONFIG_HOME).
++ * One-shot migration: ~/.config/timer/ -> ~/.config/ttym/ if the new
++ * directory doesn't already exist; legacy log file inside is renamed.
++ */
+diff -ruN a/ttym.c b/ttym.c
+--- a/ttym.c 2026-05-20 11:57:26.722347891 +0200
++++ b/ttym.c 2026-05-20 11:57:26.726347974 +0200
+@@ -12,6 +12,7 @@
+ #include <stdlib.h>
+ #include <string.h>
+ #include <sys/ioctl.h>
++#include <sys/stat.h>
+ #include <termios.h>
+ #include <time.h>
+ #include <unistd.h>
+@@ -23,6 +24,7 @@
+ typedef struct {
+ int quiet; /* -q: suppress completion bell */
+ int silent; /* -s: no bell, no extras */
++ char comment[512]; /* -c TEXT: logged with each entry */
+ } Config;
+
+ static struct termios oldtios;
+@@ -226,6 +228,367 @@
+ fflush(stdout);
+ }
+
++/* ===== log (TSV: ts<TAB>seconds<TAB>comment) ===== */
++
++static int
++build_config_path(char *buf, size_t bufsz, const char *suffix)
++{
++ const char *home = getenv("HOME");
++ const char *xdg = getenv("XDG_CONFIG_HOME");
++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/%s", xdg, suffix);
++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/%s", home, suffix);
++ else return -1;
++ return 0;
++}
++
++static void
++migrate_legacy_dir(void)
++{
++ const char *home = getenv("HOME");
++ const char *xdg = getenv("XDG_CONFIG_HOME");
++ char base[512];
++ if (xdg && *xdg) snprintf(base, sizeof(base), "%s", xdg);
++ else if (home && *home) snprintf(base, sizeof(base), "%s/.config", home);
++ else return;
++ char old_dir[600], new_dir[600];
++ snprintf(old_dir, sizeof(old_dir), "%s/timer", base);
++ snprintf(new_dir, sizeof(new_dir), "%s/ttym", base);
++ struct stat sb;
++ if (stat(new_dir, &sb) == 0) return;
++ if (stat(old_dir, &sb) != 0 || !S_ISDIR(sb.st_mode)) return;
++ if (rename(old_dir, new_dir) != 0) return;
++ char old_log[700], new_log[700];
++ snprintf(old_log, sizeof(old_log), "%s/timer.log", new_dir);
++ snprintf(new_log, sizeof(new_log), "%s/ttym.log", new_dir);
++ if (stat(old_log, &sb) == 0) rename(old_log, new_log);
++}
++
++static int
++mkdir_p(const char *path, mode_t mode)
++{
++ char buf[1024];
++ snprintf(buf, sizeof(buf), "%s", path);
++ for (char *p = buf + 1; *p; p++) {
++ if (*p == '/') {
++ *p = '\0';
++ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1;
++ *p = '/';
++ }
++ }
++ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1;
++ return 0;
++}
++
++static void
++iso_local(char *buf, size_t bufsz)
++{
++ time_t t = time(NULL);
++ struct tm tm;
++ localtime_r(&t, &tm);
++ strftime(buf, bufsz, "%Y-%m-%d %H:%M:%S", &tm);
++}
++
++static void
++sanitize_field(char *s)
++{
++ for (; *s; s++)
++ if (*s == '\t' || *s == '\n' || *s == '\r') *s = ' ';
++}
++
++static int
++is_old_format(const char *line)
++{
++ return strlen(line) >= 11 && line[10] == 'T';
++}
++
++static void
++migrate_line(const char *line, char *out, size_t outsz)
++{
++ if (strlen(line) < 19) { snprintf(out, outsz, "%s", line); return; }
++ char date[11], tpart[9];
++ memcpy(date, line, 10); date[10] = '\0';
++ memcpy(tpart, line + 11, 8); tpart[8] = '\0';
++ const char *tab = strchr(line, '\t');
++ if (!tab) { snprintf(out, outsz, "%s %s\n", date, tpart); return; }
++ snprintf(out, outsz, "%s %s%s", date, tpart, tab);
++}
++
++static void
++migrate_log_if_needed(const char *path)
++{
++ FILE *f = fopen(path, "r");
++ if (!f) return;
++ char probe[2048];
++ int needs = 0;
++ while (fgets(probe, sizeof(probe), f))
++ if (is_old_format(probe)) { needs = 1; break; }
++ if (!needs) { fclose(f); return; }
++ rewind(f);
++ char tmp[1024];
++ snprintf(tmp, sizeof(tmp), "%s.tmp", path);
++ FILE *out = fopen(tmp, "w");
++ if (!out) { fclose(f); return; }
++ char line[2048];
++ while (fgets(line, sizeof(line), f)) {
++ if (is_old_format(line)) {
++ char conv[2048];
++ migrate_line(line, conv, sizeof(conv));
++ fputs(conv, out);
++ } else fputs(line, out);
++ }
++ fclose(f);
++ fclose(out);
++ rename(tmp, path);
++}
++
++static void
++write_log(long duration_s, const char *comment)
++{
++ char path[512];
++ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return;
++ char dir[512];
++ snprintf(dir, sizeof(dir), "%s", path);
++ char *slash = strrchr(dir, '/');
++ if (slash) { *slash = '\0'; mkdir_p(dir, 0700); }
++ migrate_log_if_needed(path);
++ int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600);
++ if (fd < 0) return;
++ char ts[32]; iso_local(ts, sizeof(ts));
++ char safe[512]; snprintf(safe, sizeof(safe), "%s", comment ? comment : "");
++ sanitize_field(safe);
++ char line[1024];
++ int n = snprintf(line, sizeof(line), "%s\t%ld\t%s\n", ts, duration_s, safe);
++ if (n > 0) { ssize_t w = write(fd, line, (size_t)n); (void)w; }
++ close(fd);
++}
++
++static int
++read_log_lines(char ***out_lines, size_t *out_n)
++{
++ char path[512];
++ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return -1;
++ migrate_log_if_needed(path);
++ FILE *f = fopen(path, "r");
++ if (!f) { *out_lines = NULL; *out_n = 0; return 0; }
++ size_t cap = 64, n = 0;
++ char **lines = malloc(cap * sizeof(char *));
++ if (!lines) { fclose(f); return -1; }
++ char buf[2048];
++ while (fgets(buf, sizeof(buf), f)) {
++ size_t l = strlen(buf);
++ if (l && buf[l-1] == '\n') buf[l-1] = '\0';
++ if (!*buf) continue;
++ if (n >= cap) {
++ cap *= 2;
++ char **nl = realloc(lines, cap * sizeof(char *));
++ if (!nl) break;
++ lines = nl;
++ }
++ lines[n++] = strdup(buf);
++ }
++ fclose(f);
++ *out_lines = lines;
++ *out_n = n;
++ return 0;
++}
++
++static void
++free_lines(char **lines, size_t n)
++{
++ if (!lines) return;
++ for (size_t i = 0; i < n; i++) free(lines[i]);
++ free(lines);
++}
++
++static int
++split_log_line(char *line, char **ts, long *secs, char **comment)
++{
++ char *t1 = strchr(line, '\t'); if (!t1) return -1;
++ *t1 = '\0';
++ char *t2 = strchr(t1 + 1, '\t'); if (!t2) return -1;
++ *t2 = '\0';
++ char *end;
++ long v = strtol(t1 + 1, &end, 10);
++ if (end == t1 + 1) return -1;
++ *ts = line; *secs = v; *comment = t2 + 1;
++ return 0;
++}
++
++static void
++fmt_long(long s, char *buf, size_t bufsz)
++{
++ if (s < 0) s = 0;
++ long d = s / 86400; s %= 86400;
++ long h = s / 3600; s %= 3600;
++ long m = s / 60;
++ long sec = s % 60;
++ snprintf(buf, bufsz, "%02ld %02ld:%02ld:%02ld", d, h, m, sec);
++}
++
++static int
++cmd_read(int argc, char **argv)
++{
++ int n_tail = 0, raw = 0;
++ for (int i = 0; i < argc; i++) {
++ if (!strcmp(argv[i], "-n") && i + 1 < argc) {
++ n_tail = atoi(argv[++i]);
++ if (n_tail < 0) n_tail = 0;
++ } else if (!strcmp(argv[i], "--raw")) {
++ raw = 1;
++ } else {
++ fprintf(stderr, "timer -r: unknown arg: %s\n", argv[i]);
++ return 1;
++ }
++ }
++ char **lines = NULL; size_t n = 0;
++ if (read_log_lines(&lines, &n) < 0) return 1;
++ if (n == 0) { free_lines(lines, n); return 0; }
++ size_t start = 0;
++ if (n_tail > 0 && (size_t)n_tail < n) start = n - n_tail;
++ if (raw) {
++ for (size_t i = start; i < n; i++) puts(lines[i]);
++ } else {
++ printf("%-19s %-11s %s\n", "TIMESTAMP", "DURATION", "COMMENT");
++ for (size_t i = start; i < n; i++) {
++ char *copy = strdup(lines[i]);
++ char *ts, *cmt; long secs;
++ if (split_log_line(copy, &ts, &secs, &cmt) == 0) {
++ char dur[32];
++ fmt_long(secs, dur, sizeof(dur));
++ printf("%-19s %-11s %s\n", ts, dur, cmt);
++ } else {
++ puts(lines[i]);
++ }
++ free(copy);
++ }
++ }
++ free_lines(lines, n);
++ return 0;
++}
++
++static int
++parse_log_ts(const char *ts, struct tm *out)
++{
++ memset(out, 0, sizeof(*out));
++ if (sscanf(ts, "%d-%d-%d %d:%d:%d",
++ &out->tm_year, &out->tm_mon, &out->tm_mday,
++ &out->tm_hour, &out->tm_min, &out->tm_sec) != 6) return -1;
++ out->tm_year -= 1900;
++ out->tm_mon -= 1;
++ out->tm_isdst = -1;
++ return 0;
++}
++
++typedef struct TagAcc {
++ char tag[128];
++ long secs;
++ long count;
++ struct TagAcc *next;
++} TagAcc;
++
++static void
++tag_add(TagAcc **head, const char *tag, long secs)
++{
++ for (TagAcc *a = *head; a; a = a->next)
++ if (!strcmp(a->tag, tag)) { a->secs += secs; a->count++; return; }
++ TagAcc *n = calloc(1, sizeof(*n));
++ if (!n) return;
++ snprintf(n->tag, sizeof(n->tag), "%s", tag);
++ n->secs = secs;
++ n->count = 1;
++ n->next = *head;
++ *head = n;
++}
++
++static void
++extract_tags(const char *comment, long secs, TagAcc **head)
++{
++ const char *p = comment;
++ int found = 0;
++ while (*p) {
++ int at_boundary = (p == comment) || isspace((unsigned char)*(p-1));
++ if (*p == '+' && at_boundary) {
++ const char *s = p + 1, *e = s;
++ while (*e && !isspace((unsigned char)*e)) e++;
++ if (e > s) {
++ char buf[128];
++ size_t len = (size_t)(e - s);
++ if (len >= sizeof(buf)) len = sizeof(buf) - 1;
++ memcpy(buf, s, len); buf[len] = '\0';
++ tag_add(head, buf, secs);
++ found = 1;
++ }
++ p = e;
++ } else p++;
++ }
++ if (!found) tag_add(head, "(untagged)", secs);
++}
++
++static int
++cmd_stats(int argc, char **argv)
++{
++ const char *period = (argc > 0) ? argv[0] : "today";
++ time_t now = time(NULL);
++ struct tm now_tm; localtime_r(&now, &now_tm);
++ time_t cutoff = 0;
++ if (!strcmp(period, "all")) cutoff = 0;
++ else if (!strcmp(period, "today")) {
++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0;
++ cutoff = mktime(&t);
++ } else if (!strcmp(period, "week")) {
++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0;
++ int back = (t.tm_wday == 0) ? 6 : t.tm_wday - 1;
++ t.tm_mday -= back;
++ cutoff = mktime(&t);
++ } else if (!strcmp(period, "month")) {
++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; t.tm_mday = 1;
++ cutoff = mktime(&t);
++ } else {
++ fprintf(stderr, "stats: unknown period '%s' (today|week|month|all)\n", period);
++ return 1;
++ }
++ char **lines = NULL; size_t n = 0;
++ if (read_log_lines(&lines, &n) < 0) return 1;
++ long total = 0, count = 0;
++ TagAcc *tags = NULL;
++ for (size_t i = 0; i < n; i++) {
++ char *copy = strdup(lines[i]);
++ char *ts, *cmt; long secs;
++ if (split_log_line(copy, &ts, &secs, &cmt) != 0) { free(copy); continue; }
++ struct tm tm;
++ if (parse_log_ts(ts, &tm) != 0) { free(copy); continue; }
++ time_t entry = mktime(&tm);
++ if (cutoff && entry < cutoff) { free(copy); continue; }
++ total += secs; count++;
++ extract_tags(cmt, secs, &tags);
++ free(copy);
++ }
++ free_lines(lines, n);
++ char tot[32]; fmt_long(total, tot, sizeof(tot));
++ printf("\033[1m== timer stats: %s ==\033[0m\n", period);
++ printf("entries: %ld total: %s\n\n", count, tot);
++ int tag_n = 0;
++ for (TagAcc *a = tags; a; a = a->next) tag_n++;
++ if (tag_n > 0) {
++ TagAcc **arr = malloc(tag_n * sizeof(*arr));
++ int i = 0;
++ for (TagAcc *a = tags; a; a = a->next) arr[i++] = a;
++ for (int x = 0; x < tag_n - 1; x++)
++ for (int y = x + 1; y < tag_n; y++)
++ if (arr[y]->secs > arr[x]->secs) {
++ TagAcc *t = arr[x]; arr[x] = arr[y]; arr[y] = t;
++ }
++ printf("%-24s %-11s %5s\n", "TAG", "DURATION", "COUNT");
++ for (int z = 0; z < tag_n; z++) {
++ char d[32]; fmt_long(arr[z]->secs, d, sizeof(d));
++ printf("%-24s %-11s %5ld\n", arr[z]->tag, d, arr[z]->count);
++ }
++ free(arr);
++ }
++ while (tags) { TagAcc *next = tags->next; free(tags); tags = next; }
++ return 0;
++}
++
+ /* ===== usage =====
+ * Patches MUST update this in lockstep with new flags so -h always
+ * reflects the build.
+@@ -238,9 +601,12 @@
+ "usage:\n"
+ " timer [FLAGS] DURATION countdown\n"
+ " timer [FLAGS] stopwatch\n"
++" timer -r [-n N] [--raw] read log\n"
++" timer --stats [today|week|month|all] stats (default: today)\n"
+ "\n"
+ "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n"
+ "FLAGS:\n"
++" -c TEXT comment for log (+tag tokens are extracted)\n"
+ " -q no sound\n"
+ " -s silent\n"
+ " -- end of options\n"
+@@ -378,6 +744,7 @@
+ }
+
+ (void)cfg;
++ write_log(elapsed_s, cfg->comment);
+ return user_quit ? 130 : 0;
+ }
+
+@@ -386,6 +753,13 @@
+ int
+ main(int argc, char **argv)
+ {
++ migrate_legacy_dir();
++
++ if (argc >= 2 && !strcmp(argv[1], "-r"))
++ return cmd_read(argc - 2, argv + 2);
++ if (argc >= 2 && !strcmp(argv[1], "--stats"))
++ return cmd_stats(argc - 2, argv + 2);
++
+ Config cfg = {0};
+
+ /* Pre-strip long-form flags before getopt. Stop at "--". */
+@@ -404,10 +778,11 @@
+ new_argv[new_argc] = NULL;
+
+ int opt;
+- while ((opt = getopt(new_argc, new_argv, "qsh")) != -1) {
++ while ((opt = getopt(new_argc, new_argv, "qsc:h")) != -1) {
+ switch (opt) {
+ case 'q': cfg.quiet = 1; break;
+ case 's': cfg.silent = 1; break;
++ case 'c': snprintf(cfg.comment, sizeof(cfg.comment), "%s", optarg); break;
+ case 'h': usage(stdout); free(new_argv); return 0;
+ default: usage(stderr); free(new_argv); return 1;
+ }
diff --git a/patches/ttym-notify-2.0.diff b/patches/ttym-notify-2.0.diff
new file mode 100644
index 0000000..a80eef3
--- /dev/null
+++ b/patches/ttym-notify-2.0.diff
@@ -0,0 +1,232 @@
+diff -ruN a/config.def.h b/config.def.h
+--- a/config.def.h 2026-05-20 12:03:30.597867999 +0200
++++ b/config.def.h 2026-05-20 12:03:30.597867999 +0200
+@@ -3,3 +3,16 @@
+ /* progress bar glyphs (ASCII default) */
+ static const char *const BAR_FILL = ">";
+ static const char *const BAR_EMPTY = "-";
++
++/* desktop notification on countdown completion.
++ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded.
++ * Set NULL or "" to disable.
++ */
++static const char *const TITLE = "Timer";
++static const char *const NOTIFY_CMD = "notify-send -u critical {title} {msg}";
++
++/* sound on countdown completion. If SOUND_CMD is "" the binary autodetects
++ * a player (paplay/aplay/mpv/ffplay) and plays a default freedesktop sound.
++ * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD.
++ */
++static const char *const SOUND_CMD = "";
+diff -ruN a/ttym.c b/ttym.c
+--- a/ttym.c 2026-05-20 12:03:30.597867999 +0200
++++ b/ttym.c 2026-05-20 12:03:53.438340036 +0200
+@@ -12,6 +12,7 @@
+ #include <stdlib.h>
+ #include <string.h>
+ #include <sys/ioctl.h>
++#include <sys/wait.h>
+ #include <termios.h>
+ #include <time.h>
+ #include <unistd.h>
+@@ -23,6 +24,10 @@
+ typedef struct {
+ int quiet; /* -q: suppress completion bell */
+ int silent; /* -s: no bell, no extras */
++ const char *title;
++ const char *notify_cmd;
++ const char *sound_cmd;
++ char msg[1024];
+ } Config;
+
+ static struct termios oldtios;
+@@ -226,6 +231,142 @@
+ fflush(stdout);
+ }
+
++/* ===== subprocess + placeholder expansion =====
++ * Tokens split on whitespace; {title} and {msg} expanded within each token.
++ * No shell involved — argv stays argv.
++ */
++
++static int
++build_argv(const char *cmd, const Config *c, char ***out)
++{
++ char buf[2048];
++ snprintf(buf, sizeof(buf), "%s", cmd);
++ char **argv = calloc(64, sizeof(char *));
++ if (!argv) return -1;
++ int argc = 0;
++ char *p = buf;
++ while (*p && argc < 63) {
++ while (*p && isspace((unsigned char)*p)) p++;
++ if (!*p) break;
++ char *start = p;
++ while (*p && !isspace((unsigned char)*p)) p++;
++ if (*p) *p++ = '\0';
++ char tok[2048]; size_t oi = 0;
++ for (char *q = start; *q && oi < sizeof(tok) - 1; ) {
++ const char *sub = NULL; size_t skip = 0;
++ if (!strncmp(q, "{msg}", 5)) { sub = c->msg; skip = 5; }
++ else if (!strncmp(q, "{title}", 7)) { sub = c->title; skip = 7; }
++ if (sub) {
++ size_t sl = strlen(sub);
++ if (oi + sl >= sizeof(tok)) sl = sizeof(tok) - 1 - oi;
++ memcpy(tok + oi, sub, sl);
++ oi += sl; q += skip;
++ } else { tok[oi++] = *q++; }
++ }
++ tok[oi] = '\0';
++ argv[argc++] = strdup(tok);
++ }
++ argv[argc] = NULL;
++ *out = argv;
++ return argc;
++}
++
++static void
++free_argv(char **argv)
++{
++ if (!argv) return;
++ for (int i = 0; argv[i]; i++) free(argv[i]);
++ free(argv);
++}
++
++static void
++spawn_bg(char *const argv[])
++{
++ pid_t pid = fork();
++ if (pid == 0) {
++ setsid();
++ int devnull = open("/dev/null", O_RDWR);
++ if (devnull >= 0) {
++ dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2);
++ if (devnull > 2) close(devnull);
++ }
++ execvp(argv[0], argv);
++ _exit(127);
++ }
++ (void)pid;
++}
++
++static int
++command_exists(const char *name)
++{
++ const char *path = getenv("PATH");
++ if (!path) return 0;
++ char buf[512];
++ const char *p = path;
++ while (*p) {
++ const char *e = strchr(p, ':');
++ size_t len = e ? (size_t)(e - p) : strlen(p);
++ if (len > 0 && len + strlen(name) + 2 < sizeof(buf)) {
++ snprintf(buf, sizeof(buf), "%.*s/%s", (int)len, p, name);
++ if (access(buf, X_OK) == 0) return 1;
++ }
++ if (!e) break;
++ p = e + 1;
++ }
++ return 0;
++}
++
++static const char *
++detect_sound_player(void)
++{
++ static const char *cands[] = { "paplay", "aplay", "mpv", "ffplay", NULL };
++ for (int i = 0; cands[i]; i++)
++ if (command_exists(cands[i])) return cands[i];
++ return NULL;
++}
++
++static const char *
++default_sound_path(void)
++{
++ static const char *paths[] = {
++ "/usr/share/sounds/freedesktop/stereo/complete.oga",
++ "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga",
++ "/usr/share/sounds/alsa/Front_Center.wav",
++ NULL
++ };
++ for (int i = 0; paths[i]; i++)
++ if (access(paths[i], R_OK) == 0) return paths[i];
++ return NULL;
++}
++
++static void
++notify_and_sound(const Config *c)
++{
++ if (c->notify_cmd && c->notify_cmd[0] && command_exists("notify-send")) {
++ char **a;
++ if (build_argv(c->notify_cmd, c, &a) > 0) {
++ spawn_bg(a);
++ free_argv(a);
++ }
++ }
++ if (!c->quiet) {
++ if (c->sound_cmd && c->sound_cmd[0]) {
++ char **a;
++ if (build_argv(c->sound_cmd, c, &a) > 0) {
++ spawn_bg(a);
++ free_argv(a);
++ }
++ } else {
++ const char *pl = detect_sound_player();
++ const char *sp = default_sound_path();
++ if (pl && sp) {
++ char *a[] = { (char *)pl, (char *)sp, NULL };
++ spawn_bg(a);
++ }
++ }
++ }
++}
++
+ /* ===== usage =====
+ * Patches MUST update this in lockstep with new flags so -h always
+ * reflects the build.
+@@ -236,8 +377,8 @@
+ {
+ fprintf(f,
+ "usage:\n"
+-" timer [FLAGS] DURATION countdown\n"
+-" timer [FLAGS] stopwatch\n"
++" timer [FLAGS] DURATION [MESSAGE...] countdown\n"
++" timer [FLAGS] stopwatch\n"
+ "\n"
+ "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n"
+ "FLAGS:\n"
+@@ -370,6 +511,7 @@
+ printf("\r\033[32mDONE %s 100%%%*s\033[0m\n", tb, 20, "");
+ else
+ printf("DONE %s 100%%\n", tb);
++ if (!cfg->silent) notify_and_sound(cfg);
+ if (!cfg->silent && !cfg->quiet && out_tty) {
+ fputc('\a', stdout);
+ fflush(stdout);
+@@ -387,6 +529,10 @@
+ main(int argc, char **argv)
+ {
+ Config cfg = {0};
++ cfg.title = TITLE;
++ cfg.notify_cmd = NOTIFY_CMD;
++ cfg.sound_cmd = SOUND_CMD;
++ snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up");
+
+ /* Pre-strip long-form flags before getopt. Stop at "--". */
+ int new_argc = 0;
+@@ -424,6 +570,16 @@
+ free(new_argv);
+ return 1;
+ }
++ if (rem_argc > 1) {
++ size_t off = 0;
++ cfg.msg[0] = '\0';
++ for (int i = 1; i < rem_argc && off < sizeof(cfg.msg) - 2; i++) {
++ int n = snprintf(cfg.msg + off, sizeof(cfg.msg) - off,
++ "%s%s", (i > 1 ? " " : ""), rem_argv[i]);
++ if (n < 0) break;
++ off += (size_t)n;
++ }
++ }
+ }
+
+ int rc = run(total, stopwatch, &cfg);
diff --git a/patches/ttym-persist-alert-2.0.diff b/patches/ttym-persist-alert-2.0.diff
new file mode 100644
index 0000000..7e9d64b
--- /dev/null
+++ b/patches/ttym-persist-alert-2.0.diff
@@ -0,0 +1,111 @@
+diff -ruN a/config.def.h b/config.def.h
+--- a/config.def.h 2026-05-20 11:57:26.574344832 +0200
++++ b/config.def.h 2026-05-20 11:57:26.578344915 +0200
+@@ -3,3 +3,9 @@
+ /* progress bar glyphs (ASCII default) */
+ static const char *const BAR_FILL = ">";
+ static const char *const BAR_EMPTY = "-";
++
++/* persistent alert: keep ringing the bell until any key is pressed.
++ * Foreground-only; backgrounded runs always fall through.
++ * Override per-run with --no-persist.
++ */
++static const int ALERT_PERSIST = 1;
+diff -ruN a/ttym.c b/ttym.c
+--- a/ttym.c 2026-05-20 11:57:26.574344832 +0200
++++ b/ttym.c 2026-05-20 11:57:26.578344915 +0200
+@@ -23,6 +23,7 @@
+ typedef struct {
+ int quiet; /* -q: suppress completion bell */
+ int silent; /* -s: no bell, no extras */
++ int alert_persist; /* alert loop until keypress; --no-persist disables */
+ } Config;
+
+ static struct termios oldtios;
+@@ -226,6 +227,48 @@
+ fflush(stdout);
+ }
+
++/* ===== alert loop =====
++ * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap.
++ */
++
++static int
++is_foreground(void)
++{
++ if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) return 0;
++ pid_t pgrp = tcgetpgrp(STDIN_FILENO);
++ if (pgrp < 0) return 0;
++ return pgrp == getpgrp();
++}
++
++static void
++alert_loop(void)
++{
++ fputs("\033[?25h\033[?7h", stdout);
++ int ticks = 0;
++ while (!interrupted) {
++ fputc('\a', stdout);
++ fflush(stdout);
++ sleep_ms(150);
++
++ int acked = 0;
++ for (int i = 0; i < 15 && !interrupted; i++) {
++ if (ttyfd < 0) { sleep_ms(100); continue; }
++ struct pollfd pfd = { .fd = ttyfd, .events = POLLIN };
++ int pr = poll(&pfd, 1, 100);
++ if (pr > 0 && (pfd.revents & POLLIN)) {
++ char ch;
++ ssize_t rd = read(ttyfd, &ch, 1);
++ if (rd >= 0) { acked = 1; break; }
++ } else if (pr < 0) {
++ sleep_ms(100);
++ }
++ }
++ if (acked) break;
++ if (++ticks > 160) break;
++ }
++ fflush(stdout);
++}
++
+ /* ===== usage =====
+ * Patches MUST update this in lockstep with new flags so -h always
+ * reflects the build.
+@@ -243,6 +286,7 @@
+ "FLAGS:\n"
+ " -q no sound\n"
+ " -s silent\n"
++" --no-persist skip alert-until-keypress loop\n"
+ " -- end of options\n"
+ " -h help\n"
+ "\n"
+@@ -375,6 +419,10 @@
+ fflush(stdout);
+ sleep_ms(50);
+ }
++ if (!cfg->silent && cfg->alert_persist && is_foreground()) {
++ interrupted = 0;
++ alert_loop();
++ }
+ }
+
+ (void)cfg;
+@@ -387,6 +435,7 @@
+ main(int argc, char **argv)
+ {
+ Config cfg = {0};
++ cfg.alert_persist = ALERT_PERSIST;
+
+ /* Pre-strip long-form flags before getopt. Stop at "--". */
+ int new_argc = 0;
+@@ -399,6 +448,10 @@
+ new_argv[new_argc++] = argv[i];
+ continue;
+ }
++ if (!past_dd && !strcmp(argv[i], "--no-persist")) {
++ cfg.alert_persist = 0;
++ continue;
++ }
+ new_argv[new_argc++] = argv[i];
+ }
+ new_argv[new_argc] = NULL;