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:
@@ -166,7 +166,7 @@ func TestRemindersRelativeResolvedAtCapture(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// capture path (router) converts "in 4h" → absolute. store just takes fire_ts.
|
||||
fire := time.Now().UTC().Add(4 * time.Hour)
|
||||
id, err := s.CreateReminder(ctx, fire, `{"text":"wake me"}`)
|
||||
id, err := s.CreateReminder(ctx, fire, `{"text":"wake me"}`, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -191,6 +191,53 @@ func TestRemindersRelativeResolvedAtCapture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecurringReminder(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
// Create a daily recurring reminder at 9:00
|
||||
fire := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
|
||||
id, err := s.CreateReminder(ctx, fire, `{"text":"daily standup"}`, "0 9 * * *")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Not due yet (at 8:00, next_fire_ts = 9:00)
|
||||
if due, err := s.DueReminders(ctx, now); err != nil || len(due) != 0 {
|
||||
t.Fatalf("before fire: want 0 due, got %d (%v)", len(due), err)
|
||||
}
|
||||
|
||||
// Due at 9:00
|
||||
due, err := s.DueReminders(ctx, fire.Add(time.Second))
|
||||
if err != nil || len(due) != 1 || due[0].ID != id {
|
||||
t.Fatalf("after fire: want 1 due (%d), got %d (%v)", id, len(due), err)
|
||||
}
|
||||
if due[0].Cron != "0 9 * * *" {
|
||||
t.Fatalf("cron: want %q, got %q", "0 9 * * *", due[0].Cron)
|
||||
}
|
||||
|
||||
// Reschedule: next fire should be tomorrow 9:00
|
||||
if err := s.RescheduleReminder(ctx, id, fire); err != nil {
|
||||
t.Fatalf("RescheduleReminder: %v", err)
|
||||
}
|
||||
tomorrow := fire.Add(24 * time.Hour)
|
||||
due, err = s.DueReminders(ctx, tomorrow.Add(time.Second))
|
||||
if err != nil || len(due) != 1 || due[0].ID != id {
|
||||
t.Fatalf("after reschedule: want 1 due (%d), got %d (%v)", id, len(due), err)
|
||||
}
|
||||
|
||||
// Mark non-recurring reminder → ErrReminderState
|
||||
_, err = s.CreateReminder(ctx, fire, `{"text":"one-shot"}`, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The last id is id+1
|
||||
if err := s.RescheduleReminder(ctx, id+1, fire); !errors.Is(err, ErrReminderState) {
|
||||
t.Fatalf("reschedule one-shot: want ErrReminderState, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeOnceAndFeedbackOutcomes(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user