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
+3 -1
View File
@@ -17,7 +17,9 @@ import (
// `ALTER TABLE ...;`, // #1
// }
var migrations = []string{
`ALTER TABLE tools ADD COLUMN scope TEXT NOT NULL DEFAULT 'homelab';`,
`ALTER TABLE tools ADD COLUMN scope TEXT NOT NULL DEFAULT 'homelab';`, // #1
`ALTER TABLE reminders ADD COLUMN cron TEXT;
ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
}
// migrate applies every migration with a number greater than the DB's current
+70 -18
View File
@@ -6,16 +6,19 @@ import (
"errors"
"fmt"
"time"
"github.com/robfig/cron/v3"
)
// Reminder — user-stated future intent. fires once. relative→absolute happens
// at capture ("in 4h" → store now+4h, never the string).
// Reminder — user-stated future intent. fires once or recurring (if cron set).
type Reminder struct {
ID int64
CreatedTs time.Time
FireTs time.Time
Payload string // raw json
Status string // pending | fired | cancelled
NextFireTs time.Time // computed next fire (for recurring) or same as FireTs
Payload string // raw json
Status string // pending | fired | cancelled
Cron string // cron expression, empty for one-shot
}
var (
@@ -23,14 +26,35 @@ var (
ErrReminderState = errors.New("store: reminder not in a mutable state")
)
func scanReminder(sc scanner) (Reminder, error) {
var r Reminder
var created, fire, nextFire int64
var cron *string
if err := sc.Scan(&r.ID, &created, &fire, &nextFire, &r.Payload, &r.Status, &cron); err != nil {
return Reminder{}, err
}
r.CreatedTs = time.UnixMilli(created).UTC()
r.FireTs = time.UnixMilli(fire).UTC()
r.NextFireTs = time.UnixMilli(nextFire).UTC()
if cron != nil {
r.Cron = *cron
}
return r, nil
}
// CreateReminder persists a reminder with a resolved absolute fire time.
// The caller (router/capture path) MUST have already converted "in 4h" → now+4h.
// We do not accept strings here.
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) {
// We do not accept strings here. cron is a cron expression for recurring
// reminders; empty for one-shot.
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
now := time.Now().UTC()
var cronPtr *string
if cron != "" {
cronPtr = &cron
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO reminders (created_ts, fire_ts, payload, status) VALUES (?,?,?, 'pending')`,
now.UnixMilli(), fire.UnixMilli(), payload)
`INSERT INTO reminders (created_ts, fire_ts, next_fire_ts, payload, status, cron) VALUES (?,?,?,?, 'pending', ?)`,
now.UnixMilli(), fire.UnixMilli(), fire.UnixMilli(), payload, cronPtr)
if err != nil {
return 0, fmt.Errorf("create reminder: %w", err)
}
@@ -41,27 +65,24 @@ func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload stri
return id, nil
}
// DueReminders returns pending reminders with fire_ts <= now, oldest first.
// This is the predicate input from the loop side: `fire_ts <= now AND status='pending'`.
// DueReminders returns pending reminders with next_fire_ts <= now, oldest first.
// This is the predicate input from the loop side: `next_fire_ts <= now AND status='pending'`.
func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_ts, fire_ts, payload, status
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders
WHERE status = 'pending' AND fire_ts <= ?
ORDER BY fire_ts ASC`, now.UnixMilli())
WHERE status = 'pending' AND next_fire_ts <= ?
ORDER BY next_fire_ts ASC`, now.UnixMilli())
if err != nil {
return nil, fmt.Errorf("due reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
var r Reminder
var created, fire int64
if err := rows.Scan(&r.ID, &created, &fire, &r.Payload, &r.Status); err != nil {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
r.CreatedTs = time.UnixMilli(created).UTC()
r.FireTs = time.UnixMilli(fire).UTC()
out = append(out, r)
}
return out, rows.Err()
@@ -87,4 +108,35 @@ func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error
}
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id)
return err
}
// RescheduleReminder computes the next fire time for a recurring reminder and
// updates next_fire_ts. Returns ErrReminderState if the reminder is not
// recurring or not pending. If no more valid fire times exist, marks it fired.
func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
row := s.db.QueryRowContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders WHERE id = ?`, id)
r, err := scanReminder(row)
if err != nil {
return err
}
if r.Cron == "" {
return fmt.Errorf("%w: not a recurring reminder", ErrReminderState)
}
if r.Status != "pending" {
return fmt.Errorf("%w: currently %s", ErrReminderState, r.Status)
}
sched, err := cron.ParseStandard(r.Cron)
if err != nil {
return fmt.Errorf("parse cron %q: %w", r.Cron, err)
}
next := sched.Next(r.NextFireTs.Add(time.Minute))
if next.IsZero() || next.Before(now) {
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = 'fired' WHERE id = ?", id)
return err
}
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET next_fire_ts = ? WHERE id = ?", next.UnixMilli(), id)
return err
}
+1 -1
View File
@@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS facts (
CREATE INDEX IF NOT EXISTS idx_facts_key_ts ON facts (key, ts DESC);
CREATE INDEX IF NOT EXISTS idx_facts_voids ON facts (voids_id);
-- reminders — user intent, fires once.
-- reminders — user intent, fires once or recurring (if cron set).
-- relative→absolute happens at capture ("in 4h" → store now+4h, never the string).
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+48 -1
View File
@@ -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()