alarm + timer

This commit is contained in:
2026-07-31 13:50:53 -03:00
parent 83646ef471
commit 3a5c7de68d
8 changed files with 577 additions and 0 deletions
@@ -0,0 +1,210 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.custom.alarms-timers;
runtime = pkgs.writeShellScriptBin "alarm-runtime" ''
set -efu
state_dir="''${XDG_STATE_HOME:-$HOME/.local/state}/alarm"
state_file="$state_dir/events.json"
lock_file="$state_dir/events.lock"
mkdir -p "$state_dir"
init_state() {
if [ ! -s "$state_file" ] || ! jq -e '(.nextId | numbers) and (.events | arrays)' "$state_file" >/dev/null 2>&1; then
printf '%s\n' '{"nextId":1,"events":[]}' > "$state_file"
fi
}
write_state() {
tmp=$(mktemp "$state_dir/events.XXXXXX")
jq -c . > "$tmp"
mv -f "$tmp" "$state_file"
}
usage() {
printf '%s\n' "usage: timer DURATION|list|cancel ID" " alarm [YYYY-MM-DD] HH:MM [DESCRIPTION...]" >&2
exit 2
}
die() {
printf 'alarm: %s\n' "$1" >&2
exit 2
}
parse_duration() {
[ -n "$1" ] && [[ "$1" != *[!0-9smh]* ]] || return 1
value=''${1%[smh]}
unit=''${1#''${value}}
[ -n "$value" ] && [ "$value" -gt 0 ] || return 1
case "$unit" in s) printf '%s\n' "$value" ;; m) printf '%s\n' "$((value * 60))" ;; h) printf '%s\n' "$((value * 3600))" ;; *) return 1 ;; esac
}
valid_time() { [[ "$1" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; }
valid_date() { [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; }
create_timer() {
seconds=$(parse_duration "$1") || die "duration must be a positive number followed by s, m, or h"
now=$(date +%s)
(
flock -x 9
init_state
id=$(jq -r '.nextId' "$state_file")
jq --argjson id "$id" --argjson due "$((now + seconds))" \
'.nextId = ($id + 1) | .events += [{id:$id,kind:"timer",due:$due,description:null}]' \
"$state_file" | write_state
printf '%s\n' "$id"
) 9>"$lock_file"
}
create_alarm() {
if valid_date "$1"; then
[ "$#" -ge 2 ] || usage
alarm_date=$1; alarm_time=$2; shift 2
valid_time "$alarm_time" || die "time must use HH:MM"
else
alarm_date=$(date +%F); alarm_time=$1; shift
valid_time "$alarm_time" || die "time must use HH:MM"
candidate=$(date -d "$alarm_date $alarm_time" +%s) || die "invalid local time"
[ "$candidate" -gt "$(date +%s)" ] || alarm_date=$(date -d tomorrow +%F)
fi
candidate=$(date -d "$alarm_date $alarm_time" +%s) || die "invalid date or time"
[ "$(date -d "@$candidate" +%F\ %H:%M)" = "$alarm_date $alarm_time" ] || die "invalid date or time"
[ "$candidate" -gt "$(date +%s)" ] || die "explicit alarm date is in the past"
description="$*"
(
flock -x 9
init_state
if jq -e --argjson due "$candidate" '.events[] | select(.kind == "alarm" and .due == $due)' "$state_file" >/dev/null; then
die "an alarm already exists at that time"
fi
id=$(jq -r '.nextId' "$state_file")
jq --argjson id "$id" --argjson due "$candidate" --arg description "$description" \
'.nextId = ($id + 1) | .events += [{id:$id,kind:"alarm",due:$due,description:(if $description == "" then null else $description end)}]' \
"$state_file" | write_state
printf '%s\n' "$id"
) 9>"$lock_file"
}
list_events() {
kind=$1
(
flock -s 9
init_state
if ! jq -e --arg kind "$kind" '.events | map(select(.kind == $kind)) | length > 0' "$state_file" >/dev/null; then
printf 'No %s events scheduled.\n' "$kind"
exit 0
fi
jq -r --arg kind "$kind" '
.events | map(select(.kind == $kind)) | sort_by(.due)[] |
"\(.id) \(if .kind == "timer" then ("due " + ( .due | todateiso8601)) else ("at " + (.due | strftime("%Y-%m-%d %H:%M"))) end)\(if .description then " - " + .description else "" end)"
' "$state_file"
) 9>"$lock_file"
}
cancel_event() {
kind=$1; id=$2
[[ "$id" =~ ^[0-9]+$ ]] || die "ID must be numeric"
(
flock -x 9
init_state
if ! jq -e --arg kind "$kind" --argjson id "$id" '.events[] | select(.id == $id and .kind == $kind)' "$state_file" >/dev/null; then
die "no $kind with ID $id"
fi
jq --arg kind "$kind" --argjson id "$id" '.events |= map(select(.id != $id or .kind != $kind))' "$state_file" | write_state
printf 'Cancelled %s %s.\n' "$kind" "$id"
) 9>"$lock_file"
}
notify_event() {
kind=$1; due=$2; description=$3
title=$(printf '%s' "$kind" | tr '[:lower:]' '[:upper:]')
body="Due $(date -d "@$due" '+%Y-%m-%d %H:%M')"
[ -n "$description" ] && body="$body - $description"
notify-send -u critical "$title" "$body" || true
timeout 1 pw-play "${pkgs.sound-theme-freedesktop}/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga" >/dev/null 2>&1 || true
}
scheduler() {
started=$(date +%s)
while :; do
now=$(date +%s)
event=$(mktemp)
(
flock -x 9
init_state
jq --argjson started "$started" --argjson now "$now" \
' .events |= map(select(.due >= $started or .due > $now))' "$state_file" | write_state
jq -c --argjson started "$started" --argjson now "$now" \
'[.events[] | select(.due >= $started and .due <= $now)] | sort_by(.due) | .[0] // empty' "$state_file" > "$event"
if [ -s "$event" ]; then
jq --argjson id "$(jq -r .id "$event")" '.events |= map(select(.id != $id))' "$state_file" | write_state
fi
) 9>"$lock_file"
if [ -s "$event" ]; then
kind=$(jq -r .kind "$event"); due=$(jq -r .due "$event"); description=$(jq -r '.description // empty' "$event")
rm -f "$event"
notify_event "$kind" "$due" "$description"
else
rm -f "$event"
fi
sleep 1
done
}
waybar() {
now=$(date +%s)
init_state
jq -cn --argjson now "$now" --slurpfile state "$state_file" '
($state[0].events | map(select((.id|numbers) and (.kind == "timer" or .kind == "alarm") and (.due|numbers) and .due >= $now)) | sort_by(.due) | .[0:3]) as $events |
{text: ($events | map(if .kind == "timer" then "󰔛 " + (if (.due - $now) < 60 then ((.due - $now)|tostring) else (((.due - $now) / 3600)|floor|tostring|if length < 2 then "0" + . else . end) + ":" + ((((.due - $now) % 3600) / 60)|floor|tostring|if length < 2 then "0" + . else . end) end) else "󰥔 " + (.due | strftime("%H:%M")) end) | join(" ")), tooltip: ($events | map((.kind + " " + (.due | strftime("%Y-%m-%d %H:%M")) + (if .description then " - " + .description else "" end))) | join("\n"))}
'
}
command=''${1:-}
case "$command" in
timer) [ "$#" -ge 2 ] || usage; case "$2" in list) list_events timer ;; cancel) [ "$#" = 3 ] || usage; cancel_event timer "$3" ;; *) [ "$#" = 2 ] || usage; create_timer "$2" ;; esac ;;
alarm) [ "$#" -ge 2 ] || usage; case "$2" in list) list_events alarm ;; cancel) [ "$#" = 3 ] || usage; cancel_event alarm "$3" ;; *) create_alarm ''${@:2} ;; esac ;;
scheduler) scheduler ;;
waybar) waybar ;;
*) usage ;;
esac
'';
timer = pkgs.writeShellScriptBin "timer" ''exec ${runtime}/bin/alarm-runtime timer "$@"'';
alarm = pkgs.writeShellScriptBin "alarm" ''exec ${runtime}/bin/alarm-runtime alarm "$@"'';
scheduler = pkgs.writeShellScriptBin "alarm-scheduler" ''exec ${runtime}/bin/alarm-runtime scheduler'';
waybar = pkgs.writeShellScriptBin "alarm-waybar" ''exec ${runtime}/bin/alarm-runtime waybar'';
in
{
options.custom.alarms-timers.enable = lib.mkEnableOption "terminal alarms and timers";
config = lib.mkIf cfg.enable {
home.packages = [ timer alarm scheduler waybar pkgs.jq pkgs.libnotify pkgs.pipewire pkgs.sound-theme-freedesktop ];
home.activation.alarmsTimersState = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
run mkdir -p "$HOME/.local/state/alarm"
if [ ! -s "$HOME/.local/state/alarm/events.json" ]; then
run printf '%s\n' '{"nextId":1,"events":[]}' > "$HOME/.local/state/alarm/events.json"
fi
'';
systemd.user.services.alarm-scheduler = {
Unit = {
Description = "Terminal alarm and timer scheduler";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
Type = "simple";
ExecStart = "${scheduler}/bin/alarm-scheduler";
Restart = "on-failure";
RestartSec = 2;
};
Install.WantedBy = [ "graphical-session.target" ];
};
};
}