reminders: add recurring reminder support

Add cron expression support for recurring reminders using robfig/cron/v3.

Changes:
- Migration #2: ALTER TABLE reminders ADD COLUMN cron TEXT + next_fire_ts INTEGER
- Reminder struct: add Cron and NextFireTs fields
- scanReminder helper extracts full row including nullable cron
- CreateReminder: accept optional cron param, store next_fire_ts = fire_ts
- DueReminders: query on next_fire_ts instead of fire_ts
- RescheduleReminder: new method — parse cron, compute next fire, update
  next_fire_ts or mark fired if no more valid times
- Dispatcher: call RescheduleReminder for cron reminders, MarkReminder for
  one-shots (preserving existing behavior for ID=0 digest skip)
- ReminderCompleter interface: add RescheduleReminder method
- storeAPI adapter: forward RescheduleReminder
- All callers updated: CreateReminder signature includes cron param
- Tests: TestRecurringReminder (store), TestDispatchRecurringReminderReschedules
- Existing tests updated for new signature
This commit is contained in:
kami
2026-07-05 11:46:12 +04:00
parent 6b80fd0c0f
commit 1eca17f37b
16 changed files with 200 additions and 45 deletions
+14 -6
View File
@@ -20,11 +20,13 @@ type NudgeRecorder interface {
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
}
// ReminderCompleter — the seam the store implements. a reminder fires once:
// pending → fired after successful delivery. a failed send does NOT mark the
// reminder fired (it stays pending; the next tick re-delivers).
// ReminderCompleter — the seam the store implements. For one-shot reminders:
// pending → fired after successful delivery. For recurring reminders (with
// cron): reschedule after successful delivery. A failed send does NOT mark or
// reschedule it (it stays pending; the next tick re-delivers).
type ReminderCompleter interface {
MarkReminder(ctx context.Context, id int64, status string) error
RescheduleReminder(ctx context.Context, id int64, now time.Time) error
}
// PhrasedNudge — the phraser module's output for a nudge. the phraser (LFM
@@ -184,11 +186,17 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
}
if d.cfg.Reminders != nil && len(out) > 0 {
// ID=0 is a synthetic digest reminder; it's not in the DB so
// MarkReminder would fail with ErrReminderNotFound. The originals
// MarkReminder/RescheduleReminder would fail. The originals
// were already marked fired by collapseReminders in gather.go.
if rd.Reminder.ID != 0 {
if err := d.cfg.Reminders.MarkReminder(ctx, rd.Reminder.ID, "fired"); err != nil {
return out, fmt.Errorf("mark reminder fired: %w", err)
if rd.Reminder.Cron != "" {
if err := d.cfg.Reminders.RescheduleReminder(ctx, rd.Reminder.ID, now); err != nil {
return out, fmt.Errorf("reschedule reminder %d: %w", rd.Reminder.ID, err)
}
} else {
if err := d.cfg.Reminders.MarkReminder(ctx, rd.Reminder.ID, "fired"); err != nil {
return out, fmt.Errorf("mark reminder fired: %w", err)
}
}
}
}
+37 -1
View File
@@ -60,7 +60,8 @@ type fakeReminderCompleter struct {
id int64
status string
}
err error
rescheduled []int64
err error
}
func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status string) error {
@@ -74,6 +75,14 @@ func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status
return nil
}
func (f *fakeReminderCompleter) RescheduleReminder(_ context.Context, id int64, _ time.Time) error {
if f.err != nil {
return f.err
}
f.rescheduled = append(f.rescheduled, id)
return nil
}
type fakeAck struct {
acked map[string]bool
lastSent map[string]time.Time
@@ -599,3 +608,30 @@ func TestRepeatUnackedNilTelegramOrAckIsNoOp(t *testing.T) {
t.Fatalf("nil telegram/ack: want nil, got %+v", out)
}
}
func TestDispatchRecurringReminderReschedules(t *testing.T) {
voice := &fakeSink{}
rc := &fakeReminderCompleter{}
d := NewDispatcher(Config{Voice: voice, Reminders: rc})
rd := loop.ReminderDecision{
Reminder: store.Reminder{ID: 7, Cron: "0 9 * * *", Status: "pending"},
State: loop.State{Now: refNow(), Presence: store.Present},
}
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: rd, Body: "daily standup", Summary: "standup",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(out) != 1 {
t.Fatalf("want 1 dispatch, got %d", len(out))
}
// Should RescheduleReminder, not MarkReminder
if len(rc.rescheduled) != 1 || rc.rescheduled[0] != 7 {
t.Fatalf("want rescheduled 7, got %+v", rc.rescheduled)
}
if len(rc.marked) != 0 {
t.Fatalf("recurring reminder should not be marked fired: %+v", rc.marked)
}
}