feat: every subcommand for recurring alarms

This commit is contained in:
Lewis Wynne 2026-04-01 23:47:09 +01:00
parent 6a13649c54
commit 40790909c0
2 changed files with 367 additions and 4 deletions

316
nag
View file

@ -44,6 +44,12 @@ NAG_CMD="${NAG_CMD:-notify-send}"
# The default subcommand if no args are passed.
NAG_DEFAULT="${NAG_DEFAULT:-list}"
_VALID_RULES=(
hour day weekday weekend
monday tuesday wednesday thursday friday saturday sunday
month year
)
# The fallback subcommand if an arg is passed that is not defined.
_FALLBACK_COMMAND_IF_NO_MATCH="at"
@ -551,6 +557,254 @@ _format_time() {
printf "%s, %s" "${_date_prefix}" "${_time_fmt}"
}
# Usage:
# _validate_rules <rules_string>
#
# Description:
# Validate a comma-separated list of repeat rules. Exits with error
# if any rule is invalid.
_validate_rules() {
local _rules_str="${1:-}"
[[ -n "${_rules_str}" ]] || _exit_1 printf "No rules specified.\\n"
local IFS=","
local _rule
for _rule in ${_rules_str}
do
if ! _contains "${_rule}" "${_VALID_RULES[@]}"
then
_exit_1 printf "Invalid rule: %s\\n" "${_rule}"
fi
done
}
# Usage:
# _next_occurrence <rules> <timestamp>
#
# Description:
# Compute the next occurrence for a repeating alarm. For comma-separated
# rules, returns the earliest next occurrence across all rules.
_next_occurrence() {
local _rules_str="${1}"
local _timestamp="${2}"
local _earliest=""
local IFS=","
local _rule
for _rule in ${_rules_str}
do
IFS=$'\n\t'
local _next
_next="$(_next_for_rule "${_rule}" "${_timestamp}")"
if [[ -z "${_earliest}" ]] || (( _next < _earliest ))
then
_earliest="${_next}"
fi
done
IFS=$'\n\t'
printf "%s" "${_earliest}"
}
# Usage:
# _next_for_rule <rule> <timestamp>
#
# Description:
# Compute the next occurrence for a single repeat rule.
_next_for_rule() {
local _rule="${1}"
local _timestamp="${2}"
local _time_of_day
_time_of_day="$(date -d "@${_timestamp}" +%H:%M:%S)"
case "${_rule}" in
hour)
printf "%s" "$((_timestamp + 3600))"
;;
day)
date -d "$(date -d "@${_timestamp}" +%Y-%m-%d) + 1 day ${_time_of_day}" +%s
;;
weekday)
_next_matching_day "${_timestamp}" "${_time_of_day}" "1 2 3 4 5"
;;
weekend)
_next_matching_day "${_timestamp}" "${_time_of_day}" "6 7"
;;
monday) _next_matching_day "${_timestamp}" "${_time_of_day}" "1" ;;
tuesday) _next_matching_day "${_timestamp}" "${_time_of_day}" "2" ;;
wednesday) _next_matching_day "${_timestamp}" "${_time_of_day}" "3" ;;
thursday) _next_matching_day "${_timestamp}" "${_time_of_day}" "4" ;;
friday) _next_matching_day "${_timestamp}" "${_time_of_day}" "5" ;;
saturday) _next_matching_day "${_timestamp}" "${_time_of_day}" "6" ;;
sunday) _next_matching_day "${_timestamp}" "${_time_of_day}" "7" ;;
month)
_next_month "${_timestamp}" "${_time_of_day}"
;;
year)
_next_year "${_timestamp}" "${_time_of_day}"
;;
esac
}
# Usage:
# _next_matching_day <timestamp> <time_of_day> <target_days>
#
# Description:
# Walk forward from tomorrow until a day-of-week matches one of the
# target days. Days are space-separated, 1=Mon 7=Sun (date +%u format).
_next_matching_day() {
local _timestamp="${1}"
local _time_of_day="${2}"
local _targets="${3}"
local _base_date
_base_date="$(date -d "@${_timestamp}" +%Y-%m-%d)"
local _i
for _i in 1 2 3 4 5 6 7
do
local _candidate_ts _candidate_dow
_candidate_ts="$(date -d "${_base_date} + ${_i} day ${_time_of_day}" +%s)"
_candidate_dow="$(date -d "${_base_date} + ${_i} day" +%u)"
if [[ " ${_targets} " == *" ${_candidate_dow} "* ]]
then
printf "%s" "${_candidate_ts}"
return 0
fi
done
}
# Usage:
# _next_month <timestamp> <time_of_day>
#
# Description:
# Same day-of-month next month, same time. Clamps to last day of
# month if the day doesn't exist (e.g. 31st in a 30-day month).
_next_month() {
local _timestamp="${1}"
local _time_of_day="${2}"
local _day _month _year
_day="$(date -d "@${_timestamp}" +%-d)"
_month="$(date -d "@${_timestamp}" +%-m)"
_year="$(date -d "@${_timestamp}" +%Y)"
_month=$((_month + 1))
if (( _month > 12 ))
then
_month=1
_year=$((_year + 1))
fi
local _last_day
_last_day="$(date -d "${_year}-$(printf "%02d" "${_month}")-01 + 1 month - 1 day" +%-d)"
if (( _day > _last_day ))
then
_day="${_last_day}"
fi
date -d "$(printf "%04d-%02d-%02d %s" "${_year}" "${_month}" "${_day}" "${_time_of_day}")" +%s
}
# Usage:
# _next_year <timestamp> <time_of_day>
#
# Description:
# Same month and day next year, same time. Feb 29 in a non-leap year
# clamps to Feb 28.
_next_year() {
local _timestamp="${1}"
local _time_of_day="${2}"
local _day _month _year
_day="$(date -d "@${_timestamp}" +%-d)"
_month="$(date -d "@${_timestamp}" +%-m)"
_year="$(date -d "@${_timestamp}" +%Y)"
_year=$((_year + 1))
if ! date -d "$(printf "%04d-%02d-%02d" "${_year}" "${_month}" "${_day}")" &>/dev/null
then
local _last_day
_last_day="$(date -d "${_year}-$(printf "%02d" "${_month}")-01 + 1 month - 1 day" +%-d)"
_day="${_last_day}"
fi
date -d "$(printf "%04d-%02d-%02d %s" "${_year}" "${_month}" "${_day}" "${_time_of_day}")" +%s
}
# Usage:
# _first_occurrence <rules> <timestamp>
#
# Description:
# Compute the first valid occurrence for a repeating alarm. If the
# given timestamp already falls on a matching day, use it. Otherwise
# find the next matching day at the same time-of-day.
_first_occurrence() {
local _rules_str="${1}"
local _timestamp="${2}"
if _timestamp_matches_rule "${_rules_str}" "${_timestamp}"
then
printf "%s" "${_timestamp}"
else
# Use a timestamp from yesterday so _next_occurrence walks from today.
local _yesterday=$((_timestamp - 86400))
local _time_of_day
_time_of_day="$(date -d "@${_timestamp}" +%H:%M:%S)"
local _earliest=""
local IFS=","
local _rule
for _rule in ${_rules_str}
do
IFS=$'\n\t'
local _next
_next="$(_next_for_rule "${_rule}" "${_yesterday}")"
# Ensure we don't go before the original timestamp's time today.
if [[ -z "${_earliest}" ]] || (( _next < _earliest ))
then
_earliest="${_next}"
fi
done
IFS=$'\n\t'
printf "%s" "${_earliest}"
fi
}
# Usage:
# _timestamp_matches_rule <rules> <timestamp>
#
# Description:
# Check if a timestamp falls on a day that matches any of the given rules.
_timestamp_matches_rule() {
local _rules_str="${1}"
local _timestamp="${2}"
local _dow
_dow="$(date -d "@${_timestamp}" +%u)" # 1=Mon 7=Sun
local IFS=","
local _rule
for _rule in ${_rules_str}
do
case "${_rule}" in
hour|day) IFS=$'\n\t'; return 0 ;;
weekday) [[ " 1 2 3 4 5 " == *" ${_dow} "* ]] && { IFS=$'\n\t'; return 0; } ;;
weekend) [[ " 6 7 " == *" ${_dow} "* ]] && { IFS=$'\n\t'; return 0; } ;;
monday) [[ "${_dow}" == "1" ]] && { IFS=$'\n\t'; return 0; } ;;
tuesday) [[ "${_dow}" == "2" ]] && { IFS=$'\n\t'; return 0; } ;;
wednesday) [[ "${_dow}" == "3" ]] && { IFS=$'\n\t'; return 0; } ;;
thursday) [[ "${_dow}" == "4" ]] && { IFS=$'\n\t'; return 0; } ;;
friday) [[ "${_dow}" == "5" ]] && { IFS=$'\n\t'; return 0; } ;;
saturday) [[ "${_dow}" == "6" ]] && { IFS=$'\n\t'; return 0; } ;;
sunday) [[ "${_dow}" == "7" ]] && { IFS=$'\n\t'; return 0; } ;;
month|year) IFS=$'\n\t'; return 0 ;;
esac
done
IFS=$'\n\t'
return 1
}
# Usage:
# _parse_time <time-string>
#
@ -740,12 +994,12 @@ list() {
if [[ -n "${_rule}" ]]
then
_rule_display="every ${_rule//,/, }, "
_rule_display=" (${_rule//,/, })"
else
_rule_display=""
fi
printf "[%s] %s%s — %s\\n" "${_id}" "${_rule_display}" "${_human_time}" "${_message}"
printf "[%s] %s%s — %s\\n" "${_id}" "${_human_time}" "${_rule_display}" "${_message}"
done
}
@ -819,9 +1073,11 @@ HEREDOC
at() {
local _time_str="${1:-}"
shift || true
local _message="${*:-}"
local _message
IFS=' ' _message="${*:-}"
IFS=$'\n\t'
[[ -n "${_time_str}" ]] || _exit_1 printf "Usage: ${_ME} [at] <time> <message>\\n"
[[ -n "${_time_str}" ]] || _exit_1 printf "Usage: %s [at] <time> <message>\\n" "${_ME}"
[[ -n "${_message}" ]] || _exit_1 printf "No message specified.\\n"
local _timestamp
@ -844,5 +1100,57 @@ at() {
printf "[%s] %s — %s\\n" "${_id}" "${_human_time}" "${_message}"
}
# every ########################################################################
describe "every" <<HEREDOC
Usage:
${_ME} every <rules> <time> <message...>
Description:
Create a repeating alarm. Rules are a comma-separated list of repeat
schedules: hour, day, weekday, weekend, monday-sunday, month, year.
Examples:
${_ME} every weekday "tomorrow 9am" standup meeting
${_ME} every "tuesday,thursday" "tomorrow 3pm" team sync
HEREDOC
every() {
local _rules_str="${1:-}"
shift || true
local _time_str="${1:-}"
shift || true
local _message
IFS=' ' _message="${*:-}"
IFS=$'\n\t'
[[ -n "${_rules_str}" ]] || _exit_1 printf "Usage: %s every <rules> <time> <message...>\\n" "${_ME}"
[[ -n "${_time_str}" ]] || _exit_1 printf "Usage: %s every <rules> <time> <message...>\\n" "${_ME}"
[[ -n "${_message}" ]] || _exit_1 printf "No message specified.\\n"
_validate_rules "${_rules_str}"
local _timestamp
_timestamp="$(_parse_time "${_time_str}")"
# Snap to the first occurrence that matches the rule.
_timestamp="$(_first_occurrence "${_rules_str}" "${_timestamp}")"
_acquire_lock
local _id
_id="$(_next_id)"
# _next_id calls _read_alarms in a subshell, so _ALARMS isn't populated here.
_read_alarms
_ALARMS+=("$(printf "%s\t%s\t%s\t%s" "${_id}" "${_timestamp}" "${_rules_str}" "${_message}")")
_write_alarms
_release_lock
_prompt_cron
local _human_time
_human_time="$(_format_time "${_timestamp}")"
printf "[%s] %s (%s) — %s\\n" "${_id}" "${_human_time}" "${_rules_str//,/, }" "${_message}"
}
# _main must be called after everything has been defined.
_main

View file

@ -146,6 +146,61 @@ load test_helper
[ "${status}" -eq 1 ]
}
@test "help every shows every usage" {
run_nag help every
[ "${status}" -eq 0 ]
[[ "${output}" =~ "Usage:" ]]
[[ "${output}" =~ "every <rules> <time> <message...>" ]]
}
@test "every creates a repeating alarm" {
run_nag every weekday "tomorrow 3pm" standup meeting
[ "${status}" -eq 0 ]
[[ "${output}" =~ "[1]" ]]
[[ "${output}" =~ "(weekday)" ]]
[[ "${output}" =~ "standup meeting" ]]
# Verify TSV has rule in field 3.
local _rule
_rule="$(cut -f3 "${NAG_PATH}")"
[ "${_rule}" = "weekday" ]
}
@test "every with comma-separated rules" {
run_nag every "tuesday,thursday" "tomorrow 3pm" standup
[ "${status}" -eq 0 ]
[[ "${output}" =~ "(tuesday, thursday)" ]]
grep -q "tuesday,thursday" "${NAG_PATH}"
}
@test "every snaps to next matching day" {
# "weekend 3pm" should snap to Saturday or Sunday, not a weekday.
run_nag every weekend "tomorrow 3pm" relax
[ "${status}" -eq 0 ]
local _ts _dow
_ts="$(cut -f2 "${NAG_PATH}")"
_dow="$(date -d "@${_ts}" +%u)"
# Day-of-week should be 6 (Sat) or 7 (Sun).
[[ "${_dow}" == "6" || "${_dow}" == "7" ]]
}
@test "every with invalid rule fails" {
run_nag every "invalid_rule" "tomorrow 3pm" some message
[ "${status}" -eq 1 ]
}
@test "every without message fails" {
run_nag every weekday "tomorrow 3pm"
[ "${status}" -eq 1 ]
}
@test "list shows repeating alarm with rule in parens" {
run_nag every weekday "tomorrow 3pm" standup
run_nag
[ "${status}" -eq 0 ]
[[ "${output}" =~ "(weekday)" ]]
[[ "${output}" =~ "standup" ]]
}
@test "list sorts alarms by time" {
run_nag at "tomorrow 3pm" "take a break"
run_nag at "tomorrow 9am" "standup"