From 05d2557eac268d92870cfe2ac18b1011821d919d Mon Sep 17 00:00:00 2001 From: eltonylfgi-blip Date: Mon, 24 Aug 2026 05:06:42 +0200 Subject: [PATCH 1/2] feat: add advance usage reset reminders Send deduplicated reminders before configured reset milestones. Cover late watcher catch-up, full-quota suppression, documentation, and macOS CI. --- .github/workflows/macos-test.yml | 4 + README.md | 17 ++- lib/code-notify/utils/help.sh | 5 +- lib/code-notify/utils/usage.sh | 212 ++++++++++++++++++++++++++++++- tests/test-usage-alerts.sh | 106 +++++++++++++--- 5 files changed, 316 insertions(+), 28 deletions(-) diff --git a/.github/workflows/macos-test.yml b/.github/workflows/macos-test.yml index 6f411f4..cbc7bb9 100644 --- a/.github/workflows/macos-test.yml +++ b/.github/workflows/macos-test.yml @@ -185,6 +185,10 @@ jobs: run: | bash tests/test-notifier-sound.sh + - name: Test usage alerts and reset reminders + run: | + bash tests/test-usage-alerts.sh + - name: Test enable/disable notifications run: | export PATH="$HOME/.local/bin:$PATH" diff --git a/README.md b/README.md index 0a6da48..605893a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ cn update code-notify version ``` -Usage reset alerts are still available for Codex and Claude: `cn usage setup --watch`. +Usage reset alerts and advance reminders are available for Codex and Claude: `cn usage setup --watch`. Voice samples: [Daily reset](https://cdn.jsdelivr.net/gh/mylee04/code-notify@main/assets/audio/codex-token-daily-limit-reset.m4a) ยท [Weekly reset](https://cdn.jsdelivr.net/gh/mylee04/code-notify@main/assets/audio/codex-token-weekly-limit-reset.m4a) @@ -270,20 +270,29 @@ cn usage setup --watch cn usage status ``` -That enables usage alerts, sets the default 20% and 10% warning thresholds, enables distinct reset voice/sound, and starts a background watcher. +That enables usage alerts, sets the default 20% and 10% warning thresholds, schedules reset reminders at 48h, 24h, 12h, 6h, 2h, and 30m, enables distinct reset voice/sound, and starts a background watcher. Manual setup: ```bash cn usage on # Enable usage alerts cn usage thresholds set 20,10 # Warn at 20% and 10% remaining +cn usage reset-reminders set 48,24,12,6,2,0.5 cn usage reset-alerts voice on # Speak reset alerts cn usage reset-alerts sound default # Use the reset sound cn usage check # Run one check now cn usage watch start --interval 300 # Keep watching in the background ``` -Code-Notify checks the daily (5h) and weekly (7d) usage windows. It sends a warning when remaining usage crosses 20% or 10%, and sends a reset notification when a window returns to 100%. +Code-Notify checks the daily (5h) and weekly (7d) usage windows. It sends a warning when remaining usage crosses 20% or 10%, advance reminders while unused quota is approaching its reset, and a reset notification when a window returns to 100%. Weekly windows use all default reminder stages; 5h windows use the 2h and 30m stages. + +Each reminder is sent once per reset cycle. If the watcher was stopped while several stages passed, Code-Notify sends only the most urgent catch-up reminder instead of a burst. Customize or disable the schedule with: + +```bash +cn usage reset-reminders set 24,6,1 +cn usage reset-reminders off +cn usage reset-reminders reset +``` `cn usage check` runs once and exits. `cn usage watch start` keeps watching in the background on macOS/Linux. Use `cn usage watch stop` to stop it. @@ -302,7 +311,7 @@ cn usage reset-alerts voice off cn usage reset-alerts sound set ~/sounds/tokens-reset.wav ``` -Send reset alerts to Slack or Discord too: +Send warnings, advance reminders, and reset alerts to Slack or Discord too: ```bash cn channels add slack https://hooks.slack.com/services/... diff --git a/lib/code-notify/utils/help.sh b/lib/code-notify/utils/help.sh index c78dc77..fe87cbe 100644 --- a/lib/code-notify/utils/help.sh +++ b/lib/code-notify/utils/help.sh @@ -103,6 +103,7 @@ ${BOLD}USAGE ALERT COMMANDS:${RESET} ${GREEN}usage check${RESET} [tool] Check now and notify on threshold/reset changes ${GREEN}usage watch${RESET} [tool] [--interval seconds] ${GREEN}usage watch start${RESET} [tool] [--interval seconds] + ${GREEN}usage reset-reminders${RESET} set|off|reset ${GREEN}usage watch stop${RESET} Stop background usage watcher ${GREEN}usage thresholds set${RESET} 20,10 ${GREEN}usage reset-state${RESET} Clear usage alert dedupe state @@ -200,11 +201,13 @@ ${BOLD}USAGE:${RESET} cn usage watch stop|restart|status cn usage thresholds set 20,10 cn usage thresholds reset + cn usage reset-reminders set 48,24,12,6,2,0.5 + cn usage reset-reminders on|off|reset cn usage reset-alerts on|off cn usage reset-alerts voice on|off cn usage reset-alerts sound on|off|set |default cn usage reset-state -Usage alerts are opt-in. Use 'cn usage setup --watch' for the fastest setup. Low-usage warnings use normal Code-Notify delivery. Reset alerts have separate voice/sound controls so token reset can feel distinct from task-complete notifications. +Usage alerts are opt-in. Use 'cn usage setup --watch' for the fastest setup. Low-usage warnings and advance reset reminders use normal Code-Notify delivery. Reset alerts have separate voice/sound controls so token reset can feel distinct from task-complete notifications. EOF } diff --git a/lib/code-notify/utils/usage.sh b/lib/code-notify/utils/usage.sh index 21c41a8..6049009 100644 --- a/lib/code-notify/utils/usage.sh +++ b/lib/code-notify/utils/usage.sh @@ -17,7 +17,7 @@ ensure_usage_dir() { } usage_default_config_json() { - printf '%s\n' '{"enabled":false,"providers":["codex","claude"],"thresholds":[20,10],"reset_alerts":{"enabled":true,"voice":true,"sound":true,"sound_file":""}}' + printf '%s\n' '{"enabled":false,"providers":["codex","claude"],"thresholds":[20,10],"reset_reminders":[48,24,12,6,2,0.5],"reset_alerts":{"enabled":true,"voice":true,"sound":true,"sound_file":""}}' } usage_read_config_json() { @@ -144,6 +144,83 @@ PY success "Usage thresholds set: $thresholds" } +usage_set_reset_reminders() { + local reminder_hours="$1" + local current updated + + usage_has_python3 || { error "python3 is required to manage usage alerts"; return 1; } + [[ -n "$reminder_hours" ]] || { error "Please provide reset reminder hours, for example: cn usage reset-reminders set 48,24,12,6,2,0.5"; return 1; } + + current="$(usage_read_config_json)" + updated=$(USAGE_JSON="$current" python3 - "$reminder_hours" <<'PY' +import json +import math +import os +import sys + +raw = sys.argv[1].replace(" ", "") +if raw.lower() in ("off", "none"): + reminders = [] +else: + try: + reminders = [float(item) for item in raw.split(",") if item] + except Exception: + raise SystemExit(1) + if not reminders or any(not math.isfinite(value) or value <= 0 or value > 168 for value in reminders): + raise SystemExit(1) + reminders = sorted(set(reminders), reverse=True) + reminders = [int(value) if value.is_integer() else value for value in reminders] + +try: + data = json.loads(os.environ.get("USAGE_JSON", "{}")) +except Exception: + data = {} + +data["enabled"] = bool(data.get("enabled", False)) +data["providers"] = data.get("providers") or ["codex", "claude"] +data["thresholds"] = data.get("thresholds") or [20, 10] +data["reset_reminders"] = reminders +data["reset_alerts"] = data.get("reset_alerts") or {"enabled": True, "voice": True, "sound": True, "sound_file": ""} +print(json.dumps(data, separators=(",", ":"))) +PY +) || { error "Reset reminders must be comma-separated hours between 0 and 168"; return 1; } + usage_write_config_json "$updated" || return 1 + + if [[ "$reminder_hours" =~ ^([Oo][Ff][Ff]|[Nn][Oo][Nn][Ee])$ ]]; then + success "Usage reset reminders disabled" + else + success "Usage reset reminders set: $reminder_hours hours" + fi +} + +usage_reset_reminders_default() { + usage_set_reset_reminders "48,24,12,6,2,0.5" +} + +usage_handle_reset_reminders_command() { + local subcommand="${1:-status}" + + case "$subcommand" in + "status"|"") + usage_status + ;; + "set") + usage_set_reset_reminders "${2:-}" + ;; + "on"|"reset") + usage_reset_reminders_default + ;; + "off") + usage_set_reset_reminders "off" + ;; + *) + error "Unknown reset-reminders command: $subcommand" + show_usage_help + return 1 + ;; + esac +} + usage_set_reset_alert_field() { local field="$1" local value="$2" @@ -284,7 +361,7 @@ usage_reset_thresholds() { } usage_reset_state() { - usage_write_state_json '{"thresholds":{},"resets":{}}' + usage_write_state_json '{"thresholds":{},"resets":{},"reminders":{}}' success "Usage alert state reset" } @@ -340,6 +417,24 @@ except Exception: print("enabled\t" + ("true" if data.get("enabled", False) else "false")) print("thresholds\t" + ",".join(str(v) for v in data.get("thresholds", [20, 10]))) +reminders = data["reset_reminders"] if "reset_reminders" in data else [48, 24, 12, 6, 2, 0.5] +if not isinstance(reminders, (list, tuple)): + reminders = [] +reminder_labels = [] +for value in reminders: + try: + hours = float(value) + except Exception: + continue + if not (hours > 0 and hours < float("inf")): + continue + if hours == 0.5: + reminder_labels.append("30m") + elif hours.is_integer(): + reminder_labels.append(f"{int(hours)}h") + else: + reminder_labels.append(f"{hours:g}h") +print("reset_reminders\t" + ",".join(reminder_labels)) reset_alerts = data.get("reset_alerts") or {} print("reset_alerts\t" + ("true" if reset_alerts.get("enabled", True) else "false")) print("reset_voice\t" + ("true" if reset_alerts.get("voice", True) else "false")) @@ -362,6 +457,13 @@ PY thresholds) echo " Thresholds: ${CYAN}$value${RESET}" ;; + reset_reminders) + if [[ -n "$value" ]]; then + echo " Reset reminders: ${GREEN}ENABLED${RESET} (${CYAN}$value${RESET})" + else + echo " Reset reminders: ${DIM}DISABLED${RESET}" + fi + ;; reset_alerts) if [[ "$value" == "true" ]]; then echo " Reset alerts: ${GREEN}ENABLED${RESET}" @@ -518,12 +620,41 @@ usage_state_events() { if [[ -f "$USAGE_STATE_FILE" ]]; then state="$(cat "$USAGE_STATE_FILE")" else - state='{"thresholds":{},"resets":{}}' + state='{"thresholds":{},"resets":{},"reminders":{}}' fi updated_output=$(python3 - "$config" "$state" "$quota_lines" <<'PY' import json +import math +import os import sys +import time +from datetime import datetime, timezone + + +def parse_reset_epoch(value): + if not value or value == "null": + return None + try: + epoch = float(value) + if not math.isfinite(epoch): + return None + if epoch > 10_000_000_000: + epoch /= 1000 + return epoch + except (TypeError, ValueError): + pass + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.timestamp() + except (TypeError, ValueError): + return None + + +def hour_marker(value): + return str(int(value)) if float(value).is_integer() else f"{value:g}" config = json.loads(sys.argv[1] or "{}") try: @@ -532,8 +663,25 @@ except Exception: state = {} quota_lines = sys.argv[3].splitlines() thresholds = sorted({int(v) for v in config.get("thresholds", [20, 10])}, reverse=True) +reset_reminders = [] +configured_reminders = config["reset_reminders"] if "reset_reminders" in config else [48, 24, 12, 6, 2, 0.5] +if not isinstance(configured_reminders, (list, tuple)): + configured_reminders = [] +for value in configured_reminders: + try: + hours = float(value) + except (TypeError, ValueError): + continue + if math.isfinite(hours) and 0 < hours <= 168: + reset_reminders.append(hours) +reset_reminders = sorted(set(reset_reminders), reverse=True) +try: + now_epoch = float(os.environ.get("CODE_NOTIFY_USAGE_NOW_EPOCH", time.time())) +except (TypeError, ValueError): + now_epoch = time.time() state.setdefault("thresholds", {}) state.setdefault("resets", {}) +state.setdefault("reminders", {}) events = [] for line in quota_lines: @@ -552,6 +700,40 @@ for line in quota_lines: else: state["resets"][reset_key] = {"full": False} + reset_epoch = parse_reset_epoch(reset_at) + if remaining >= 100: + state["reminders"].pop(reset_key, None) + elif reset_epoch is not None and reset_epoch > now_epoch and reset_reminders: + window_span_hours = {"5h": 5, "7d": 168}.get(window) + applicable = [value for value in reset_reminders if window_span_hours is None or value <= window_span_hours] + seconds_until_reset = reset_epoch - now_epoch + due = [value for value in applicable if seconds_until_reset <= value * 3600] + + reminder_entry = state["reminders"].get(reset_key) + same_cycle = False + notified = set() + if isinstance(reminder_entry, dict): + try: + same_cycle = abs(float(reminder_entry.get("reset_epoch", 0)) - reset_epoch) <= 900 + except (TypeError, ValueError): + same_cycle = False + if same_cycle: + for value in reminder_entry.get("hours", []): + try: + notified.add(float(value)) + except (TypeError, ValueError): + pass + + unseen_due = [value for value in due if value not in notified] + if unseen_due: + marker = min(unseen_due) + events.append(["reset_reminder", provider, window, str(remaining), reset_at, hour_marker(marker)]) + notified.update(due) + state["reminders"][reset_key] = { + "reset_epoch": reset_epoch, + "hours": sorted(notified, reverse=True), + } + for threshold in thresholds: key = f"{provider}:{window}:{threshold}" was_below = bool(state["thresholds"].get(key, {}).get("below", False)) @@ -603,6 +785,14 @@ usage_window_voice_label() { esac } +usage_reset_reminder_label() { + case "$1" in + "0.5") printf '%s\n' "30 minutes" ;; + "1") printf '%s\n' "1 hour" ;; + *) printf '%s\n' "$1 hours" ;; + esac +} + usage_send_event() { local kind="$1" local provider="$2" @@ -610,7 +800,7 @@ usage_send_event() { local remaining="$4" local reset_at="$5" local marker="$6" - local title message voice_message notifier display_name window_label window_voice_label reset_enabled reset_voice reset_sound reset_sound_file hook_type + local title message voice_message notifier display_name window_label window_voice_label reminder_label reset_enabled reset_voice reset_sound reset_sound_file hook_type display_name="$(usage_display_name "$provider")" window_label="$(usage_window_label "$window")" @@ -634,12 +824,19 @@ usage_send_event() { voice_message="$display_name usage is low" hook_type="usage" ;; + reset_reminder) + reminder_label="$(usage_reset_reminder_label "$marker")" + title="$display_name $window_voice_label reset soon" + message="$display_name $window_label resets in under $reminder_label. ${remaining}% remains; use it before the reset." + voice_message="$display_name $window_voice_label resets in under $reminder_label" + hook_type="usage" + ;; *) return 0 ;; esac - if [[ -n "$reset_at" && "$reset_at" != "null" ]]; then + if [[ "$kind" != "reset_reminder" && -n "$reset_at" && "$reset_at" != "null" ]]; then message="$message Reset: $reset_at" fi @@ -857,6 +1054,7 @@ usage_setup() { usage_set_enabled "$normalized" true || return 1 usage_set_thresholds "20,10" >/dev/null || return 1 + usage_reset_reminders_default >/dev/null || return 1 usage_set_reset_alert_field enabled true || return 1 usage_set_reset_alert_field voice true || return 1 usage_set_reset_alert_field sound true || return 1 @@ -865,6 +1063,7 @@ usage_setup() { success "Usage reset alerts configured" info "Providers: $normalized" info "Thresholds: 20%, 10%" + info "Reset reminders: 48h, 24h, 12h, 6h, 2h, 30m" info "Reset alerts: voice and distinct reset sound enabled" if [[ "$start_watch" == "true" ]]; then @@ -981,6 +1180,9 @@ handle_usage_command() { ;; esac ;; + "reset-reminders") + usage_handle_reset_reminders_command "$@" + ;; "reset-alerts") usage_handle_reset_alerts_command "$@" ;; diff --git a/tests/test-usage-alerts.sh b/tests/test-usage-alerts.sh index 9811cda..a7e91a5 100644 --- a/tests/test-usage-alerts.sh +++ b/tests/test-usage-alerts.sh @@ -48,23 +48,34 @@ cat > "$fake_bin/curl" < "$quota_count" - case "\$count" in - 1) used=5 ;; - 2) used=80 ;; - 3) used=91 ;; - 4) used=91 ;; - 5) used=70 ;; - 6) used=91 ;; - 7) used=50 ;; - 8) used=0 ;; - 9) used=0 ;; - 10) used=10 ;; - *) used=0 ;; - esac - printf '{"rate_limit":{"primary_window":{"used_percent":%s,"reset_at":1900000000},"secondary_window":{"used_percent":50,"reset_at":1900000000}}}' "\$used" + if [[ -n "\${CODE_NOTIFY_TEST_RESET_AT:-}" ]]; then + used="\${CODE_NOTIFY_TEST_PRIMARY_USED:-50}" + secondary_used="\${CODE_NOTIFY_TEST_SECONDARY_USED:-50}" + primary_reset_at="\${CODE_NOTIFY_TEST_PRIMARY_RESET_AT:-\$CODE_NOTIFY_TEST_RESET_AT}" + secondary_reset_at="\$CODE_NOTIFY_TEST_RESET_AT" + else + count=\$(cat "$quota_count") + count=\$((count + 1)) + printf '%s' "\$count" > "$quota_count" + case "\$count" in + 1) used=5 ;; + 2) used=80 ;; + 3) used=91 ;; + 4) used=91 ;; + 5) used=70 ;; + 6) used=91 ;; + 7) used=50 ;; + 8) used=0 ;; + 9) used=0 ;; + 10) used=10 ;; + *) used=0 ;; + esac + secondary_used=50 + primary_reset_at=1900000000 + secondary_reset_at=1900000000 + fi + printf '{"rate_limit":{"primary_window":{"used_percent":%s,"reset_at":%s},"secondary_window":{"used_percent":%s,"reset_at":%s}}}' \ + "\$used" "\$primary_reset_at" "\$secondary_used" "\$secondary_reset_at" exit 0 fi printf '%s\n' "\$*" >> "$curl_log" @@ -143,6 +154,65 @@ PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ disabled_reset_status=$(PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" "$ROOT_DIR/bin/code-notify" usage status) printf '%s' "$disabled_reset_status" | grep -q "Reset alerts: .*DISABLED" || fail "reset alerts should be optional" +reminder_home="$test_dir/reminder-home" +mkdir -p "$reminder_home/.codex" +printf '{"tokens":{"access_token":"codex-token"}}' > "$reminder_home/.codex/auth.json" +PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" HOME="$reminder_home" \ + "$ROOT_DIR/bin/code-notify" usage setup codex >/dev/null + +reminder_reset_at=2000169200 +reminder_primary_reset_at=2000014400 +reminder_now=2000000000 + +run_reminder_check() { + PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + HOME="$reminder_home" \ + CODE_NOTIFY_USAGE_NOW_EPOCH="$reminder_now" \ + CODE_NOTIFY_TEST_PRIMARY_RESET_AT="$reminder_primary_reset_at" \ + CODE_NOTIFY_TEST_RESET_AT="$reminder_reset_at" \ + CODE_NOTIFY_TEST_PRIMARY_USED="${reminder_primary_used:-50}" \ + CODE_NOTIFY_TEST_SECONDARY_USED="${reminder_secondary_used:-24}" \ + "$ROOT_DIR/bin/code-notify" usage check codex >/dev/null +} + +: > "$notify_log" +run_reminder_check +grep -q "weekly (7d) resets in under 48 hours. 76% remains" "$notify_log" || fail "48 hour reset reminder missing" +reminder_count=$(wc -l < "$notify_log") +[[ "$reminder_count" -eq 1 ]] || fail "5h window should not emit long-horizon reset reminders" + +run_reminder_check +same_reminder_count=$(wc -l < "$notify_log") +[[ "$same_reminder_count" -eq "$reminder_count" ]] || fail "same reset reminder should not duplicate" + +reminder_now=$((reminder_reset_at - 23 * 3600)) +run_reminder_check +grep -q "weekly (7d) resets in under 24 hours" "$notify_log" || fail "24 hour reset reminder missing" + +reminder_now=$((reminder_reset_at - 5 * 3600)) +before_late_count=$(wc -l < "$notify_log") +run_reminder_check +after_late_count=$(wc -l < "$notify_log") +[[ "$after_late_count" -eq $((before_late_count + 1)) ]] || fail "late watcher should emit only one catch-up reminder" +grep -q "weekly (7d) resets in under 6 hours" "$notify_log" || fail "late watcher should choose the most urgent reminder" + +reminder_now=$((reminder_reset_at - 20 * 60)) +run_reminder_check +grep -q "weekly (7d) resets in under 30 minutes" "$notify_log" || fail "30 minute reset reminder missing" + +: > "$notify_log" +PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" HOME="$reminder_home" \ + "$ROOT_DIR/bin/code-notify" usage reset-state >/dev/null +reminder_primary_used=0 +reminder_secondary_used=0 +run_reminder_check +[[ ! -s "$notify_log" ]] || fail "full quota should not emit a reset reminder" + +PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" HOME="$reminder_home" \ + "$ROOT_DIR/bin/code-notify" usage reset-reminders off >/dev/null +reminder_status=$(PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" HOME="$reminder_home" "$ROOT_DIR/bin/code-notify" usage status) +printf '%s' "$reminder_status" | grep -q "Reset reminders: .*DISABLED" || fail "reset reminders should be optional" + watch_home="$test_dir/watch-home" mkdir -p "$watch_home/.codex" printf '{"tokens":{"access_token":"codex-token"}}' > "$watch_home/.codex/auth.json" @@ -155,4 +225,4 @@ PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" HOME="$watch_home" \ stopped_status=$(PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" HOME="$watch_home" "$ROOT_DIR/bin/code-notify" usage watch status) printf '%s' "$stopped_status" | grep -q "Watcher: .*STOPPED" || fail "usage watcher should stop cleanly" -pass "usage alerts detect Codex thresholds and reset transitions without duplicates" +pass "usage alerts detect Codex thresholds, reset reminders, and reset transitions without duplicates" From 92adaf5d8290d099cf9e589483f74645c117c91b Mon Sep 17 00:00:00 2001 From: Kevinlee49 Date: Fri, 28 Aug 2026 10:07:01 -0500 Subject: [PATCH 2/2] fix: preserve reset reminder dedupe across full quota --- lib/code-notify/utils/usage.sh | 5 ++--- tests/test-usage-alerts.sh | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/lib/code-notify/utils/usage.sh b/lib/code-notify/utils/usage.sh index 6049009..c906ada 100644 --- a/lib/code-notify/utils/usage.sh +++ b/lib/code-notify/utils/usage.sh @@ -701,9 +701,8 @@ for line in quota_lines: state["resets"][reset_key] = {"full": False} reset_epoch = parse_reset_epoch(reset_at) - if remaining >= 100: - state["reminders"].pop(reset_key, None) - elif reset_epoch is not None and reset_epoch > now_epoch and reset_reminders: + # Full quota suppresses delivery, but only a new reset cycle re-arms reminders. + if remaining < 100 and reset_epoch is not None and reset_epoch > now_epoch and reset_reminders: window_span_hours = {"5h": 5, "7d": 168}.get(window) applicable = [value for value in reset_reminders if window_span_hours is None or value <= window_span_hours] seconds_until_reset = reset_epoch - now_epoch diff --git a/tests/test-usage-alerts.sh b/tests/test-usage-alerts.sh index a7e91a5..effa2f9 100644 --- a/tests/test-usage-alerts.sh +++ b/tests/test-usage-alerts.sh @@ -185,6 +185,20 @@ run_reminder_check same_reminder_count=$(wc -l < "$notify_log") [[ "$same_reminder_count" -eq "$reminder_count" ]] || fail "same reset reminder should not duplicate" +# A full-quota observation must not erase reminders already sent in this cycle. +reminder_secondary_used=1 +run_reminder_check +before_full_reminder_count=$(grep -c "weekly (7d) resets in under" "$notify_log") +reminder_secondary_used=0 +run_reminder_check +full_reminder_count=$(grep -c "weekly (7d) resets in under" "$notify_log") +[[ "$full_reminder_count" -eq "$before_full_reminder_count" ]] || fail "full quota should suppress advance reminders" +reminder_secondary_used=1 +run_reminder_check +after_full_reminder_count=$(grep -c "weekly (7d) resets in under" "$notify_log") +[[ "$after_full_reminder_count" -eq "$before_full_reminder_count" ]] || fail "99 -> 100 -> 99 percent in the same reset cycle should not repeat a reminder" +reminder_secondary_used=24 + reminder_now=$((reminder_reset_at - 23 * 3600)) run_reminder_check grep -q "weekly (7d) resets in under 24 hours" "$notify_log" || fail "24 hour reset reminder missing" @@ -200,6 +214,17 @@ reminder_now=$((reminder_reset_at - 20 * 60)) run_reminder_check grep -q "weekly (7d) resets in under 30 minutes" "$notify_log" || fail "30 minute reset reminder missing" +# A new reset timestamp must re-arm reminders even without observing full quota. +reminder_reset_at=$((reminder_reset_at + 7 * 24 * 3600)) +reminder_now=$((reminder_reset_at - 47 * 3600)) +before_new_cycle_count=$(grep -c "weekly (7d) resets in under 48 hours" "$notify_log") +run_reminder_check +new_cycle_count=$(grep -c "weekly (7d) resets in under 48 hours" "$notify_log") +[[ "$new_cycle_count" -eq $((before_new_cycle_count + 1)) ]] || fail "new reset cycle should re-arm the 48 hour reminder" +run_reminder_check +repeated_new_cycle_count=$(grep -c "weekly (7d) resets in under 48 hours" "$notify_log") +[[ "$repeated_new_cycle_count" -eq "$new_cycle_count" ]] || fail "new reset cycle reminder should still be deduplicated" + : > "$notify_log" PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" HOME="$reminder_home" \ "$ROOT_DIR/bin/code-notify" usage reset-state >/dev/null