Skip to content

release: v0.4.0 — 週期性提醒 - #30

Merged
HeiTang merged 30 commits into
mainfrom
dev
Jul 10, 2026
Merged

release: v0.4.0 — 週期性提醒#30
HeiTang merged 30 commits into
mainfrom
dev

Conversation

@HeiTang

@HeiTang HeiTang commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

v0.4.0 主打 週期性提醒,涵蓋規則資料模型、自然語言 parser、確認卡 / 列表 / 詳情 / 到期訊息四個 UX surface、到期後的按鈕行為,以及無效輸入的單輪拒絕與 defensive validation。

Added

  • 週期性提醒每天 09:00每週一 09:00每個月 1, 18, 25 號 09:00每年 12/25 08:00 都能一句自然語言建立,到期後自動排下一次。
  • INTERVAL 週期每 N 分鐘 / 小時 / 天 / 週(含「日 / 週 / 周」別名),下限 10 分鐘。首次觸發等於 now + interval(無 time-of-day 概念)。
  • 週期到期新按鈕[跳過下次][取消整個系列],取代不合語意的 snooze。
  • [重複] 前綴/list 對重複提醒特別標示。
  • 無效 marker 單輪拒絕每個月 45 號 / 每年 2/30 / 每 1 分鐘 / 每 0 分鐘 / 超大 N 都會回具體錯誤原因並要求重打,不建 draft、不追問。
  • 啟動時 log 版本與關鍵設定(Redeploy verification)。

Changed

  • 週期性到期不再帶 snooze 按鈕(避免與週期節奏語意重疊);一次性提醒延後按鈕維持不變。
  • render_recurrence_error 模板改為 reason 自帶語意,讓 date / interval 錯誤訊息語氣一致。

Fixed

  • command_body(bot_username=None) crash(v0.3.1 預存 bug)
  • Race conditions:advance_pending / cancel / snooze 全部加 rowcount 檢查
  • snooze 對 recurring reminder 之前 DOA — SQL 加 PENDING 狀態允許
  • Cross-timezone claim_duejulianday() 做絕對時間比較
  • Scheduler 用 reminder 自己的時區算 next_fire
  • 多個 format_rule / next_fire / serialize_rule defensive validation gaps

Persistence

  • Schema v4:reminders.recurrence(JSON blob)
  • Schema v5:reminder_drafts.recurrence
  • INTERVAL 不需要新 migration,沿用 v4 JSON blob

一句話

v0.3.1 之後 dev 累積 30 個 commit,含 6 個 feature/fix PR(#22 #23 #24 #25 #26 #27 #28 #29),222 tests 綠。

Test plan

  • Full make check(ruff + 222 unit tests + smoke flow)綠
  • 手動快測 v0.4.0 核心情境(每 N 分鐘各單位、單輪拒絕訊息、週期到期按鈕縮減、既有 daily/weekly/monthly/yearly 沒 regress)
  • Merge 後在 main 建 v0.4.0 tag 與 GitHub Release

HeiTang and others added 30 commits July 3, 2026 01:44
Redeploys were previously silent about which build was actually
running. Add a startup banner that logs the package version and the
resolved settings (bot username, timezone, database path, schema
version, poll timeout, scheduler interval, draft/confirming TTLs,
log level) so operators can confirm at a glance which build is live.

Token is intentionally excluded and covered by a test to prevent
accidental future leaks.

Generated with AI

Co-Authored-By: AI <ai@example.com>
feat: log version and key settings on startup
Phase 1 of recurring reminders: persist a recurrence rule per
reminder, compute the next fire deterministically, and re-schedule
recurring reminders after each delivery instead of marking them
FIRED. No natural language parsing yet (that is phase 2) and no UX
changes (phase 3).

- Add RecurrenceRule dataclass with RecurrencePeriod enum covering
  daily / weekly / monthly / yearly with hour + minute and the
  period-specific selectors (weekdays / month_days / year_month +
  year_day).
- Add next_fire(rule, after) pure function. Strictly returns times
  after `after` so a tick landing on the exact target does not
  reschedule to the same second. Monthly scans up to 3 months
  ahead (handles 31 in short months), yearly up to 5 years ahead
  (handles Feb 29 in non-leap years).
- Add serialize_rule / deserialize_rule so the DB stores only the
  fields actually used by each period.
- Schema v4: add nullable `recurrence` text column on reminders.
  Existing rows are unaffected (one-time reminders keep recurrence
  = NULL and behave exactly as before).
- Repository: create_reminder persists recurrence; add reschedule
  (FIRING → PENDING with new remind_at) guarded on FIRING to avoid
  double-processing; row_to_reminder deserialises when present.
- Scheduler tick: if reminder.recurrence is set, send + reschedule
  to next_fire; otherwise unchanged mark_fired path.

Tests cover: serialize/deserialize roundtrip for all four periods;
next_fire behaviour for each period including the day-31-in-Feb
skip, leap year skip, and same-second-rollover; persistence
roundtrip; reschedule status guard; scheduler integration that
verifies FIRED is not called for recurring reminders.

Generated with AI

Co-Authored-By: AI <ai@example.com>
next_fire uses after.tzinfo to construct the target HH:MM. Passing
scheduler-timezone `now` therefore anchored the "every day 09:00"
rule to the scheduler host tz, so a UTC-hosted bot serving an
Asia/Taipei user would fire at 09:00 UTC (17:00 Taipei) instead of
09:00 Taipei. Convert now to reminder.timezone before calling
next_fire; keep `now` for reschedule() metadata unchanged.

Regression test asserts a daily 09:00 Asia/Taipei rule ticked from a
UTC scheduler reschedules to next-day 09:00 in Asia/Taipei
(not 09:00 UTC).

Generated with AI

Co-Authored-By: AI <ai@example.com>
Lexicographic ISO string comparison in claim_due / list_pending
mishandles reminders whose remind_at is stored with a different tz
offset than the scheduler's now — the same instant may sort as
"greater" or "less" depending on the two offsets. Concretely, a bot
hosted in UTC serving Asia/Taipei users would never claim their
reminders because e.g. "2026-07-03T09:00:00+08:00" is not
lexicographically <= "2026-07-03T01:00:00+00:00" even though they are
the same moment.

Wrap remind_at comparisons and ordering in julianday(), which parses
ISO 8601 (including the timezone offset and optional microseconds)
into a Julian Day number so comparison reflects the absolute instant
regardless of how it is written. Regression tests add a cross-tz
claim_due hit and a cross-tz just-before-due miss.

Also fix a comment/behavior mismatch in next_fire yearly branch: the
comment claimed the loop only scanned "this year and next year", but
the actual range was 5. Bump range to 9 to cover the century leap
year gap (2096 leap → 2100 non-leap → 2104 leap = 8 years) and
update the comment. Regression test locks the 2097 → 2104 case.

Generated with AI

Co-Authored-By: AI <ai@example.com>
feat(recurrence): phase 1 — data model, storage, scheduler
Phase 2 of recurring reminders: recognise common Chinese recurrence
phrases at parse time, produce a RecurrenceRule alongside the
first-fire remind_at, and thread it through draft → confirm →
persisted Reminder so Phase 1's scheduler picks it up unchanged.

Supported markers (all require an explicit HH:MM in the same
message; missing time falls through to the one-time parser):

- 每天 / 每日 09:00 ... → DAILY
- 每週一 / 每禮拜一 / 每星期一 09:00 ... → WEEKLY single day
- 每週一三五 / 每週一、三、五 / 每週一,三,五 09:00 ... → WEEKLY multi
- 每個月 1 號 / 每月 1 號 09:00 ... → MONTHLY single day
- 每個月 1, 18, 25 號 / 每個月 1、18、25 號 09:00 ... → MONTHLY multi
- 每年 12月25號 / 每年 12/25 08:00 ... → YEARLY

Parser calls next_fire(rule, now) to seed the first remind_at, so
the scheduler's existing recurrence branch (Phase 1) picks it up on
the very first tick after creation without any extra plumbing.

Wiring:
- ParseResult and ReminderDraft gain optional recurrence fields.
- Service.begin_create copies parse_result.recurrence onto the draft;
  confirm() copies draft.recurrence onto the created Reminder.
- Schema v5: reminder_drafts.recurrence text column (nullable). Reuses
  the existing recurrence.serialize/deserialize helpers for consistency
  with the reminders table.

Out of scope (phase 3): confirmation card wording, /list display,
edit flow for recurring rules, skip-once button. This PR keeps the UX
identical — the confirmation card still shows a single remind_at
(first fire), but under the hood the reminder repeats.

Tests:
- 12 parser cases covering all 4 periods and all separator variants,
  including the user's original example "每個月 1, 18, 25 號 09:00".
- Draft persistence roundtrip for recurrence.
- Service end-to-end: begin_create populates draft.recurrence and
  confirm transfers it to the stored Reminder.
- Non-recurring regression case ensuring existing parser paths are
  not disturbed.

Generated with AI

Co-Authored-By: AI <ai@example.com>
Monthly regex allowed 0..99 for day-of-month, and yearly regex
allowed any 1..2-digit month and day. Both patterns then fed the
raw values straight into next_fire, which called datetime(y, m, d,
...) with unchecked inputs. A message like "每個月 0 號 09:00 …"
or "每年 13/40 08:00 …" would raise ValueError from the datetime
constructor (monthly) or exhaust the 9-year scan and raise
RuntimeError (yearly), and both errors bubbled out of parse() —
the bot would fail to respond to that update.

- Filter monthly month_days to 1..31 in `_detect_recurrence_marker`.
  If everything is filtered out, fall through to the one-off parser
  instead of building an empty rule. Partial-valid input is kept
  (e.g. "15, 45 號" keeps 15 and drops 45).
- For yearly, validate the (month, day) pair by attempting
  datetime(2028, month, day). 2028 is a leap year so 2/29 is accepted
  as a valid rule (yearly next_fire already handles the leap-year
  skip). Combinations that never exist (2/30, 4/31, 13/1, ...) are
  rejected and fall through.
- Also add the same "empty tuple" guard on weekly so a marker with
  no recognisable weekday chars falls through cleanly.

Regression tests cover the zero/out-of-range month days, partial
valid days, invalid month, impossible date, and 2/29 acceptance.

Generated with AI

Co-Authored-By: AI <ai@example.com>
The recurrence path never used the zone parameter — timezone
information is carried by `now.tzinfo`, and `next_fire` derives its
own zone from `after.tzinfo`. Keeping a declared-but-unused zone
argument is misleading for anyone reading or modifying the code
later (they might think swapping it changes behaviour). Remove it
from the signature and update the sole call site.

Generated with AI

Co-Authored-By: AI <ai@example.com>
The recurrence path had its own leading-connector strip pattern that
included 要 (`^\s*(?:和|跟|與|要|在)\s*`), while the one-off split
path deliberately keeps 要 (so "1號要去家樂福" stays intact). That
inconsistency broke the "UX unchanged" claim: a phrase like
"每天 09:00 提醒我要運動" would produce title "運動" via the
recurrence path but "要運動" via the one-off path for equivalent
sentence structure.

Strip only what is specific to recurrence — the marker, the time,
and 提醒(我/我們/大家)? — then delegate to the shared
_clean_content_title for participant / mention / connector cleanup.
Both paths now produce identical titles for the same tail phrasing.

Regression test asserts "每天 09:00 提醒我要運動" → title="要運動".

Generated with AI

Co-Authored-By: AI <ai@example.com>
feat(recurrence): phase 2 — natural language parser for recurrence
Phase 3 of recurring reminders. Backend was fully wired in phases 1
and 2, but nothing on the user-facing side reflected that a reminder
was recurring: the confirmation card, the created message, /list,
the detail view, and the delivery notification all rendered the
first fire time as if it were a one-off.

Add `format_rule(rule)` in recurrence.py that turns a RecurrenceRule
into a Chinese-readable string:

  DAILY 09:00                          → 每天 09:00
  WEEKLY weekdays=(0,)  09:00          → 每週一 09:00
  WEEKLY weekdays=(0,2,4) 09:00        → 每週一、三、五 09:00
  MONTHLY month_days=(15,) 09:00       → 每月 15 號 09:00
  MONTHLY month_days=(1,18,25) 09:00   → 每月 1, 18, 25 號 09:00
  YEARLY  12/25 08:00                  → 每年 12/25 08:00

Use it in the renderer to distinguish recurring from one-off:

- render_confirmation / render_created / render_detail: replace the
  "時間:..." line with two lines "重複:<rule>" and "下次:<first
  fire>", so users can see the pattern and confirm the first time
  before committing.
- render_delivery: append "重複:<rule>" to the delivery notification
  so users know they will get it again (not compute-next to keep the
  renderer decoupled from scheduler timing).
- render_grouped_list and reminder_list_keyboard: prefix recurring
  reminders with "[重複]" for at-a-glance scanning.

Out of scope (phase 4, if wanted):
- Editing the recurrence rule (currently edit only covers title/time).
- "跳過本次" button on delivery.
- "取消整個系列" on delivery.

Generated with AI

Co-Authored-By: AI <ai@example.com>
`format_rule` previously mirrored the happy-path assumption baked
into `next_fire` without matching its validation. That let it emit
malformed strings for invalid rules and even crash on out-of-range
weekdays:

- WEEKLY with empty `weekdays` rendered as "每週 09:00".
- YEARLY with missing `year_month`/`year_day` rendered as "每年 None/None 09:00".
- WEEKLY with a weekday outside 0..6 raised `IndexError` from
  `_CHINESE_WEEKDAYS[d]` instead of a caller-actionable `ValueError`.
- Weekdays / month_days were joined in declaration order; a rule
  built manually without `sorted()` would surface unsorted labels
  (parser always sorts, but the model type does not enforce it).

Align `format_rule` validation with `next_fire`: raise `ValueError`
for empty weekly weekdays, empty monthly month_days, missing yearly
fields, and any out-of-range value; sort weekdays / month_days
defensively before joining so the user-facing string is stable
regardless of how the rule was constructed.

Also add a `reminder_list_keyboard` regression test asserting the
`[重複]` prefix appears on recurring buttons and not on one-off
buttons, symmetric with the existing `render_grouped_list` test.

Generated with AI

Co-Authored-By: AI <ai@example.com>
format_rule now raises for empty weekly/monthly values and
out-of-range weekdays/month days, but yearly month/day were still
accepted as-is. A rule with year_month=13 or year_day=40 would
silently render "每年 13/40 08:00", contradicting the "raise on
malformed" contract added in the previous commit.

Parser already had a `_is_valid_month_day` helper (probing with a
leap year so 2/29 is accepted, 2/30 / 4/31 / 13/1 rejected). Promote
it to a public `is_valid_month_day` in recurrence.py so parser and
format_rule share one source of truth, and use it in the yearly
format_rule branch. This avoids validation drift between the two
callers.

Regression tests cover out-of-range yearly month (13/25), impossible
yearly date (4/31), and confirmed acceptance of the leap-day case
(2/29 renders as "每年 2/29 08:00").

Generated with AI

Co-Authored-By: AI <ai@example.com>
The previous docstring claimed format_rule's validation "aligned
with next_fire", but that is not quite right: format_rule now
additionally rejects out-of-range weekday/month_day values and
invalid yearly dates up front, whereas next_fire only enforces
required-field presence and would otherwise scan-until-not-found.

Rephrase the docstring to make the actual contract explicit — same
required-field rules as next_fire, plus stricter value-range and
date-existence checks — and explain why the two callers differ:
format_rule produces user-visible strings so must reject malformed
rules immediately, while next_fire runs on scheduler ticks where
finding a valid future occurrence is the only concern. This avoids
misleading future maintainers who might assume the two functions
validate identically.

No behaviour change; docstring only.

Generated with AI

Co-Authored-By: AI <ai@example.com>
format_rule already rejects out-of-range weekdays, month_days, and
yearly dates, but hour and minute were passed through unchecked. A
corrupted DB payload (or a manually constructed rule) with hour=99
would render "99:99" instead of failing fast — inconsistent with
the "reject malformed rule" contract stated in the docstring.

Add hour ∈ 0..23 and minute ∈ 0..59 checks before formatting. Kept
inside format_rule for now (matching the other range checks); the
longer-term cleanup would be to move all validation into
RecurrenceRule.__post_init__ so no invalid rule can exist, but that
touches serialisation / persistence and is deferred.

Generated with AI

Co-Authored-By: AI <ai@example.com>
Line 49 unconditionally called re.escape(bot_username) before the
None guard on line 50, so command_body raised TypeError whenever
BOT_USERNAME was unset. That propagated through command_handlers.
handle → router.handle_update → app.py's outer try/except, which
silently logged the error, and users saw no response to any slash
command. Bots deployed without BOT_USERNAME were effectively DOA
for /start, /help, /remind, /list, /cancel, /timezone, /groupmode.

Move the None check before pattern construction so re.escape only
sees a real string. Add a test module (tests/test_text.py) that
locks down both branches (bot_username=None accepts bare and
"@anybot" suffixes; bot_username set accepts bare and matching
suffix), plus baseline strip_bot_mention coverage that was
missing.

Generated with AI

Co-Authored-By: AI <ai@example.com>
fix(text): command_body no longer crashes when bot_username is None
feat(recurrence): phase 3 — show recurrence rule across all UX surfaces
Recurring reminders had no way to say "I'm going on vacation, skip
the next one" or "I'm done with this series entirely" from the
delivery notification itself. The only escape was /cancel <short_id>
which needs the user to type or copy the short id. Phase 4a adds two
buttons to the delivery keyboard when reminder.recurrence is set.

Renderer:
- Add `delivery_recurring_keyboard(short_id, next_day_time_label)`
  that keeps the three snooze buttons and appends a row with
  "跳過下次" and "取消整個系列". The existing one-off keyboard is
  unchanged, so ordinary reminders are not affected.
- Add `render_skipped_next` / `render_series_cancelled` used to
  overwrite the delivery message once the user picks either action.

Scheduler:
- Pick between the two keyboards based on `reminder.recurrence`.
  Recurring reminders get the extended keyboard; one-off keeps the
  three-button variant.

Service:
- `skip_next_occurrence(chat_id, short_id, actor, now)` runs
  `next_fire(rule, current_remind_at)` to compute the occurrence
  *after* the currently scheduled one, then calls
  `repository.advance_pending` (new) to update remind_at while
  status is still PENDING. Non-recurring reminders return None so
  the caller can surface a clear "not applicable" answer.
- `cancel_series(chat_id, short_id, actor)` delegates to the
  existing `repository.cancel` which already enforces the creator
  guard; the callback layer only needs to reply and repaint.

Repository:
- `advance_pending` mirrors `snooze` (creator guard, PENDING → new
  remind_at) but keeps status PENDING and does not touch fired_at.
  Semantically different from `reschedule` (which is scheduler-owned
  and transitions FIRING → PENDING).

Callback layer:
- New `skip_next` and `cancel_series` handlers. Both edit the
  delivery message in place with cleared keyboard so the same row
  cannot be triggered twice.

Tests:
- Service: skip advances one iteration (7/18 → 7/25),
  skip crosses month boundary (7/25 → 8/1), non-recurring rejected,
  non-creator rejected, cancel_series marks CANCELLED, non-creator
  rejected on cancel too.
- Scheduler: recurring delivery uses the extended keyboard;
  one-off delivery does not.

Generated with AI

Co-Authored-By: AI <ai@example.com>
advance_pending() ran SELECT then UPDATE in the same connection but
never checked whether the UPDATE actually hit a row. If scheduler
claim_due changed status from PENDING to FIRING between the two
statements (different connection / worker thread), the update would
be 0 rows affected while the method still returned a "success"
Reminder object with the new remind_at — DB and return value out of
sync, and the calling flow (skip_next callback) would repaint the
delivery card as if the skip succeeded.

Check result.rowcount and return None when it is not exactly 1.
Also expand the callback error message so users are not misled
when the reason is not "not recurring" — the skip flow also returns
None for missing reminder, non-creator, and the race case.

Regression test: mark reminder as FIRING before calling
skip_next_occurrence; assert None is returned and the stored
remind_at is unchanged.

Generated with AI

Co-Authored-By: AI <ai@example.com>
Follow-ups from the phase-4a review:

1. `_skip_next` error text now covers the "在處理中" case that was
   introduced by the round-1 rowcount guard. Users clicking right at
   the fire moment (PENDING → FIRING race) will no longer be told
   they lack permission or that the reminder does not exist.

2. `_cancel_series` error text has the same problem: `cancel_series`
   delegates to `repository.cancel` which only accepts PENDING, so a
   click during the fire window returned None with a misleading
   "not creator / not found" message. Broaden it symmetrically to
   include "在處理中".

3. `delivery_recurring_keyboard` was copy-pasting the three snooze
   button rows from `delivery_snooze_keyboard`, which would drift
   the next time either side edits button text, ordering, or
   callback payload. Delegate to `delivery_snooze_keyboard` for the
   base rows and only append the phase-4a row on top. Behaviour is
   unchanged, verified by the existing scheduler keyboard tests.

4. Add missing renderer tests for `render_skipped_next` and
   `render_series_cancelled` — they are the primary messages users
   see after clicking the new buttons, so both content and HTML
   escaping matter. Coverage now matches the other render_*
   helpers.

Generated with AI

Co-Authored-By: AI <ai@example.com>
Same race window we fixed in advance_pending exists in
repository.cancel — SELECT the reminder while it is PENDING, then
run an UPDATE guarded on status = PENDING. If scheduler.claim_due
transitions the row to FIRING between the two statements (different
connection), the UPDATE is 0 rows affected but the method still
returns a Reminder object with status=CANCELLED synthesised
locally. Users see a "already cancelled" reply while the DB row is
untouched and continues to fire on the next cycle.

Add the same result.rowcount != 1 → return None guard. This is
pre-existing but exposed by Phase 4a's cancel-series button (the
same call path is also used by /cancel and the detail-page delete,
so both benefit).

Regression test mirrors the skip_next race test: pre-mark reminder
as FIRING, call cancel_series, expect None returned and DB row
status still FIRING (not CANCELLED).

Generated with AI

Co-Authored-By: AI <ai@example.com>
The snooze SQL only accepted status IN (FIRING, FIRED) but the
scheduler.tick recurrence branch calls repository.reschedule
immediately after send_message, transitioning the row from FIRING
back to PENDING before the user has time to click any delivery
button. So the "10 分鐘後 / 1 小時後 / 明天 HH:MM" row that phase
4a delegated onto the recurring keyboard has been silently DOA
since Phase 1: every click returned None and the reply text mumbled
about "not creator or not found" without matching reality.

Add PENDING to both the SELECT status filter and the guarded UPDATE
so recurring reminders can be snoozed while pending. Behavior for
one-off reminders is unchanged (they still snooze from FIRING or
FIRED). Add the same rowcount != 1 guard as advance_pending /
repository.cancel for the SELECT → UPDATE race window with
scheduler.claim_due.

Interaction with the recurrence schedule: snoozing overwrites the
already-scheduled next fire with `now + delay`. When that snooze
fire runs, tick calls next_fire(rule, now) which returns the next
occurrence per the original rule, so the recurrence pattern
self-heals — the user only sees an extra "remind me later"
notification for the current instance.

Regression test constructs a PENDING recurring reminder (mirroring
the state after tick's reschedule) and confirms 10m snooze returns
click_at + 10 minutes.

Generated with AI

Co-Authored-By: AI <ai@example.com>
cancel() only replaced `status` on the returned Reminder while the
UPDATE wrote a fresh `updated_at` to the database. The returned
object therefore reported the pre-cancel timestamp, out of sync
with the persisted row. Every other update-shaped repository method
(snooze, advance_pending, update_title, update_remind_at) already
returns updated_at from the mutation moment; cancel was the outlier.

Keep the mutation timestamp as a datetime alongside the ISO string
we hand to SQLite, then pass it through `replace(...)` on the
returned Reminder so callers see the same value that landed in the
DB.

Regression test asserts the returned reminder's updated_at differs
from the pre-cancel row and matches what get_by_short_id reads back.

Generated with AI

Co-Authored-By: AI <ai@example.com>
The button label is "取消整個系列" and the edit message after
success is "已取消整個系列", but the callback toast omitted "整個"
and said "已取消系列". Match the button/edit wording so users see
consistent wording across the entire click flow.

Generated with AI

Co-Authored-By: AI <ai@example.com>
…d-cancel

feat(recurrence): phase 4a — skip next + cancel series on delivery
* feat(parser): single-turn reject for invalid recurrence markers

Before this change, "每個月 45 號 09:00 提醒我" would silently
downgrade to a one-off reminder for "tomorrow 09:00" with title
"每個月 45 號" — the marker text bleeding into the title while the
user's intent (a monthly recurrence) was ignored. Same for
"每年 13/25", "每年 2/30", "每年 4/31", etc. The recurrence path
already rejected these at validation but simply returned None and
let the split parser scavenge whatever it could from the leftovers.

Introduce a proper "single-turn reject" signal:

- Add RecurrenceError(marker_text, reason) dataclass to models.
- Extend ParseResult with recurrence_error field.
- _detect_recurrence_marker now distinguishes three outcomes:
  valid tuple, RecurrenceError, or None. Monthly with any 1..31
  values still succeeds (partial-valid tolerated); only all-invalid
  raises. Yearly reports a specific reason (month out of range vs
  month max days) using the same leap-year probe as is_valid_month_day.
- _try_recurrence_parse propagates RecurrenceError; parse() sets it
  on ParseResult so callers can react without inspecting internals.
- service.preview_parse lets router peek at parse output without any
  save side-effect; begin_create accepts a pre-computed parse_result
  to avoid double-parsing after a successful preview.
- renderer.render_recurrence_error + response_sender.send_recurrence_error
  emit the user-visible message.
- Router runs preview_parse first: on recurrence_error it sends the
  error message and returns without touching pending draft/edit
  sessions, so the user's in-progress work is not clobbered by a
  typo.

Tests cover parser signals for all invalid inputs (monthly 0/45,
yearly 13/25, 2/30, 4/31), the partial-valid tolerance path, the
happy-path 2/29 leap-day case, router's single-turn reject flow,
and the "invalid recurrence does not disturb a pending draft" case.

Generated with AI

Co-Authored-By: AI <ai@example.com>

* fix(service): defensive begin_create for invalid recurrence markers

Copilot review round 1 flagged that begin_create() would still build and
persist a draft when parse_result carried a recurrence_error. Any entry
point that skipped the router's preview_parse guard (e.g. /remind) would
regress to the pre-#27 UX: silent draft + "什麼時候提醒?" follow-up.

Add RecurrenceRejected result type and short-circuit inside begin_create
so the guard sits at the layer closest to the side effect. send_draft_result
dispatches it to send_recurrence_error. Also fix parser docstring that
said "raise" instead of "return" for RecurrenceError.

Generated with AI

Co-Authored-By: AI <ai@example.com>

---------

Co-authored-by: AI <ai@example.com>
* feat(recurrence): support interval-based rules (每 N 分鐘/小時/天/週)

Extend the RecurrenceRule dataclass with a new INTERVAL period and an
interval_seconds field so users can say "每 15 分鐘提醒我喝水" or
"每 2 週提醒我倒垃圾". INTERVAL rules do not carry a time-of-day; next_fire
just adds the interval to the current tick.

Parser gains RECURRENCE_INTERVAL_RE with unit aliases (天/日, 週/周). Below
INTERVAL_MIN_SECONDS (10 min) the parser emits a RecurrenceError → router
single-turn reject (『每 1 分鐘』太頻繁, 最低支援 10 分鐘). Above the
threshold a valid rule is built and title extraction reuses the existing
"strip marker + 提醒(我/我們/大家)?" flow; a missing title still falls
through to the normal draft prompt.

Storage does not need a migration — recurrence is a JSON blob and
serialize/deserialize gained an INTERVAL branch that omits hour/minute.
format_rule picks the largest evenly-divisible unit so 900s renders as
"每 15 分鐘" and 604800s as "每 1 週".

render_recurrence_error template dropped the date-specific "不是有效的日期"
wrapper; parser reasons are now self-contained (「日期無效,需在 1-31 範圍」/
「太頻繁,最低支援 10 分鐘」) so both date and interval errors read
naturally.

Test count: 203 → 216.

Generated with AI

Co-Authored-By: AI <ai@example.com>

* refactor(delivery): drop snooze buttons from recurring reminder keyboard

Recurring reminders will fire again on schedule, so the snooze buttons
(10 分/1 小時/明天 HH:MM) inherited from one-off delivery add no signal
over 跳過下次: they either overlap ("明天 09:15" on a DAILY 09:00 rule is
a no-op) or drift the cadence (INTERVAL every 15 min + snooze 10 min ends
up on a shifted grid). Trim delivery_recurring_keyboard down to the two
actions that are actually distinct: skip the next occurrence, or cancel
the whole series. Users who need a precise one-shot delay can /remind a
new reminder.

Signature dropped next_day_time_label since the label was only used by
the snooze buttons. Scheduler now branches so it only computes the label
for one-off reminders.

Generated with AI

Co-Authored-By: AI <ai@example.com>

* fix(recurrence): defensive checks + boundary handling for interval

Copilot round 1 flagged four gaps in the INTERVAL feature:

1. RECURRENCE_INTERVAL_RE matches n=0, which falls into the "太頻繁" bucket
   and emits a misleading reason. Split the check: seconds<=0 → "間隔需為
   正整數"; below INTERVAL_MIN_SECONDS → "太頻繁". Kept \d+ so 「每 0 分鐘」
   still hits a marker path (a stricter [1-9]\d* would silently fall through
   to "什麼時候提醒?", worse UX).
2. Very large n (「每 99999999999 週」) raises OverflowError inside
   `now + timedelta`, crashing begin_create. Catch in the INTERVAL branch
   of `_try_recurrence_parse` and return RecurrenceError.
3. `serialize_rule` accepted INTERVAL rules without validating
   interval_seconds, letting bad data reach the DB and blow up
   deserialize with `int(None)`. Align validation with next_fire /
   format_rule and raise early. Same guard for missing hour/minute
   on non-interval rules.
4. Optional hour/minute made `next_fire(RecurrenceRule(period=DAILY))`
   raise TypeError from `after.replace(hour=None)`. Add explicit None
   guard on the non-INTERVAL path so callers see ValueError with a
   readable message.

Test count: 216 → 221.

Generated with AI

Co-Authored-By: AI <ai@example.com>

* fix(recurrence): fail-fast on non-divisible interval_seconds

Copilot round 2 flagged that `_format_interval_seconds` silently
truncates to minutes when interval_seconds does not divide evenly into
any supported unit (601s → 「每 10 分鐘」 while the schedule is actually
601s). Parser guarantees a 60-multiple so this is unreachable via the
normal path, but if a bad value ever reaches format_rule (e.g. DB hand
edit), raising ValueError is more debuggable than a silently drifting
UI. Aligns with the defensive raise pattern already used in next_fire /
serialize_rule.

Generated with AI

Co-Authored-By: AI <ai@example.com>

* style(parser): unify rejection prefix on yearly impossible-date reason

Copilot round 3 noted that yearly "day exceeds month max" produced a
reason like "2 月最多 29 天" — reads as a statement of fact instead of a
rejection, unlike monthly ("日期無效,需在 1-31 範圍") and interval
("太頻繁,最低支援 10 分鐘") which carry their own reject-tone prefix.
Prepend "日期無效" so render_recurrence_error emits a consistent
message: 「『每年 2/30』日期無效,2 月最多 29 天。」.

Generated with AI

Co-Authored-By: AI <ai@example.com>

---------

Co-authored-by: AI <ai@example.com>
- __version__ 0.3.1 → 0.4.0 (pyproject.toml, __init__.py, uv.lock).
- CHANGELOG documents recurring reminders (data model, parser, UX),
  INTERVAL period, single-turn reject for invalid markers, recurring
  delivery keyboard slim-down, and defensive fixes across the storage
  layer.
- README features section covers recurring examples + clarifies that
  snooze buttons are one-off only.

Generated with AI

Co-authored-by: AI <ai@example.com>
@HeiTang
HeiTang merged commit 72b0778 into main Jul 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant