From 1eca17f37b5e151db248fc6ab09e89277b519ac9 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 5 Jul 2026 11:46:12 +0400 Subject: [PATCH] reminders: add recurring reminder support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- SESSION-05-07-2026.md | 2 +- cmd/mavend/tick_test.go | 2 +- cmd/mavend/voice.go | 2 +- go.mod | 1 + go.sum | 2 + internal/auth/auth_test.go | 2 +- internal/delivery/dispatcher.go | 20 +++++-- internal/delivery/dispatcher_test.go | 38 +++++++++++- internal/ipc/api.go | 17 +++--- internal/ipc/client.go | 4 +- internal/ipc/ipc_test.go | 2 +- internal/ipc/server.go | 10 +++- internal/store/migrations.go | 4 +- internal/store/reminders.go | 88 ++++++++++++++++++++++------ internal/store/schema.sql | 2 +- internal/store/store_test.go | 49 +++++++++++++++- 16 files changed, 200 insertions(+), 45 deletions(-) diff --git a/SESSION-05-07-2026.md b/SESSION-05-07-2026.md index 5dc2d27..1959db4 100644 --- a/SESSION-05-07-2026.md +++ b/SESSION-05-07-2026.md @@ -61,7 +61,7 @@ Notes: | # | Task | Commit | Status | |---|------|--------|--------| | 12 | **Recurring reminders** — cron expression in reminder table. New `cron` column, `next_fire_ts` computed from cron. "trash every tuesday" | — | pending | -| 13 | **Capability model** — `scope:name` instead of flat `tool→enabled`. Schema: add `scope` to tools table. Backward-compat: bare name = `homelab:name` | — | pending | +| 13 | **Capability model** — `scope` column on tools table, migration, UI, tests | 6b80fd0 | done | | 14 | **Notification batching / digest mode** — morning/evening rollup instead of per-event nudges. Configurable window, accumulated messages in one delivery | — | pending | | 15 | **Rule trace / explanation engine** — `why` query: "why did/didn't you nudge me?" Reads predicate eval log. New `/trace` page or CLI query | — | pending | | 16 | **Backup/restore automation** — script: `cp` the encrypted DB + re-encrypt-verify + restore flow | — | pending | diff --git a/cmd/mavend/tick_test.go b/cmd/mavend/tick_test.go index 250129c..c4ac858 100644 --- a/cmd/mavend/tick_test.go +++ b/cmd/mavend/tick_test.go @@ -150,7 +150,7 @@ func TestTickReminderFiresOnceAndMarkedFired(t *testing.T) { st := newTestStore(t) ctx := context.Background() now := refNow() - if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"stand up"}`); err != nil { + if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"stand up"}`, ""); err != nil { t.Fatalf("CreateReminder: %v", err) } sink := &fakeSink{} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 7bb646b..43c478d 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -354,7 +354,7 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) return "не получилось разобрать время напоминания." } payload := `{"text":` + jsonString(dec.Utterance) + `}` - if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload); err != nil { + if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil { log.Printf("voice: create reminder: %v", err) return "не получилось поставить напоминание." } diff --git a/go.mod b/go.mod index f0b5660..95da339 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/yalue/onnxruntime_go v1.31.0 // indirect golang.org/x/text v0.3.0 // indirect modernc.org/libc v1.55.3 // indirect diff --git a/go.sum b/go.sum index 7886815..ee77ec4 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA= github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 85b5466..83e676d 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -335,7 +335,7 @@ func (r *recordingAPI) Since(_ context.Context, _ string, _ time.Time) (time.Dur func (r *recordingAPI) Presence(_ context.Context) (ipc.Presence, error) { return ipc.Presence{}, nil } -func (r *recordingAPI) CreateReminder(_ context.Context, _ time.Time, _ string) (int64, error) { +func (r *recordingAPI) CreateReminder(_ context.Context, _ time.Time, _, _ string) (int64, error) { return 1, nil } func (r *recordingAPI) MarkReminder(_ context.Context, _ int64, _ string) error { return nil } diff --git a/internal/delivery/dispatcher.go b/internal/delivery/dispatcher.go index 430b3ee..f08b0f1 100644 --- a/internal/delivery/dispatcher.go +++ b/internal/delivery/dispatcher.go @@ -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) + } } } } diff --git a/internal/delivery/dispatcher_test.go b/internal/delivery/dispatcher_test.go index 37d5921..1fdc9eb 100644 --- a/internal/delivery/dispatcher_test.go +++ b/internal/delivery/dispatcher_test.go @@ -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) + } +} diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 01a5708..e4b671d 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -51,13 +51,15 @@ type Note struct { Score float64 `json:"score"` } -// Reminder — user-stated future intent; fires once. +// Reminder — user-stated future intent; fires once or recurring (if cron set). type Reminder struct { - ID int64 `json:"id"` - CreatedTs time.Time `json:"created_ts"` - FireTs time.Time `json:"fire_ts"` - Payload string `json:"payload"` - Status string `json:"status"` // pending|fired|cancelled + ID int64 `json:"id"` + CreatedTs time.Time `json:"created_ts"` + FireTs time.Time `json:"fire_ts"` + NextFireTs time.Time `json:"next_fire_ts"` + Payload string `json:"payload"` + Status string `json:"status"` // pending|fired|cancelled + Cron string `json:"cron"` } // Presence — the read the phraser / delivery modules need to decide channel @@ -137,6 +139,7 @@ type queryNotesReq struct { type createReminderReq struct { Fire time.Time `json:"fire"` Payload string `json:"payload"` + Cron string `json:"cron"` } type recordNudgeReq struct { Rule string `json:"rule"` @@ -212,7 +215,7 @@ type CoreAPI interface { LatestFactBySource(ctx context.Context, key, source string) (Fact, error) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) Presence(ctx context.Context) (Presence, error) - CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) + CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) MarkReminder(ctx context.Context, id int64, status string) error RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error diff --git a/internal/ipc/client.go b/internal/ipc/client.go index e9aab0f..37e449b 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -237,9 +237,9 @@ func (c *Client) Presence(ctx context.Context) (Presence, error) { return p, nil } -func (c *Client) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) { +func (c *Client) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { var r idResp - if err := c.call(ctx, MethodCreateReminder, createReminderReq{Fire: fire, Payload: payload}, &r); err != nil { + if err := c.call(ctx, MethodCreateReminder, createReminderReq{Fire: fire, Payload: payload, Cron: cron}, &r); err != nil { return 0, err } return r.ID, nil diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go index 385df23..9883ef6 100644 --- a/internal/ipc/ipc_test.go +++ b/internal/ipc/ipc_test.go @@ -311,7 +311,7 @@ func TestClient_E2E(t *testing.T) { } // reminder lifecycle: create → mark fired → re-mark ⇒ ErrReminderState. - rid, err := cli.CreateReminder(ctx, now.Add(time.Hour), `{"text":"wake me 7"}`) + rid, err := cli.CreateReminder(ctx, now.Add(time.Hour), `{"text":"wake me 7"}`, "") if err != nil { t.Fatalf("CreateReminder: %v", err) } diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 52c3300..432ee6f 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -68,8 +68,8 @@ func (a *storeAPI) Presence(ctx context.Context) (Presence, error) { return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil } -func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) { - id, err := a.s.CreateReminder(ctx, fire, payload) +func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { + id, err := a.s.CreateReminder(ctx, fire, payload, cron) return id, mapErr(err) } @@ -77,6 +77,10 @@ func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) er return mapErr(a.s.MarkReminder(ctx, id, status)) } +func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error { + return mapErr(a.s.RescheduleReminder(ctx, id, now)) +} + func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { id, err := a.s.RecordNudge(ctx, rule, channel, message, ts) return id, mapErr(err) @@ -457,7 +461,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er if err := unmarshalParams(req.Params, &p); err != nil { return nil, err } - id, err := s.api.CreateReminder(ctx, p.Fire, p.Payload) + id, err := s.api.CreateReminder(ctx, p.Fire, p.Payload, p.Cron) return marshalResult(idResp{ID: id}), err case MethodMarkReminder: diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 4c3e8fe..01925d0 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -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 diff --git a/internal/store/reminders.go b/internal/store/reminders.go index 8f0dcf0..27df951 100644 --- a/internal/store/reminders.go +++ b/internal/store/reminders.go @@ -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 } \ No newline at end of file diff --git a/internal/store/schema.sql b/internal/store/schema.sql index 71bc019..388ca65 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -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, diff --git a/internal/store/store_test.go b/internal/store/store_test.go index c374a54..b1321bd 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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()