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
+1 -1
View File
@@ -61,7 +61,7 @@ Notes:
| # | Task | Commit | Status | | # | Task | Commit | Status |
|---|------|--------|--------| |---|------|--------|--------|
| 12 | **Recurring reminders** — cron expression in reminder table. New `cron` column, `next_fire_ts` computed from cron. "trash every tuesday" | — | pending | | 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 | | 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 | | 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 | | 16 | **Backup/restore automation** — script: `cp` the encrypted DB + re-encrypt-verify + restore flow | — | pending |
+1 -1
View File
@@ -150,7 +150,7 @@ func TestTickReminderFiresOnceAndMarkedFired(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
now := refNow() 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) t.Fatalf("CreateReminder: %v", err)
} }
sink := &fakeSink{} sink := &fakeSink{}
+1 -1
View File
@@ -354,7 +354,7 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
return "не получилось разобрать время напоминания." return "не получилось разобрать время напоминания."
} }
payload := `{"text":` + jsonString(dec.Utterance) + `}` 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) log.Printf("voice: create reminder: %v", err)
return "не получилось поставить напоминание." return "не получилось поставить напоминание."
} }
+1
View File
@@ -14,6 +14,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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 github.com/yalue/onnxruntime_go v1.31.0 // indirect
golang.org/x/text v0.3.0 // indirect golang.org/x/text v0.3.0 // indirect
modernc.org/libc v1.55.3 // indirect modernc.org/libc v1.55.3 // indirect
+2
View File
@@ -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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA=
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
+1 -1
View File
@@ -335,7 +335,7 @@ func (r *recordingAPI) Since(_ context.Context, _ string, _ time.Time) (time.Dur
func (r *recordingAPI) Presence(_ context.Context) (ipc.Presence, error) { func (r *recordingAPI) Presence(_ context.Context) (ipc.Presence, error) {
return ipc.Presence{}, nil 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 return 1, nil
} }
func (r *recordingAPI) MarkReminder(_ context.Context, _ int64, _ string) error { return nil } func (r *recordingAPI) MarkReminder(_ context.Context, _ int64, _ string) error { return nil }
+14 -6
View File
@@ -20,11 +20,13 @@ type NudgeRecorder interface {
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
} }
// ReminderCompleter — the seam the store implements. a reminder fires once: // ReminderCompleter — the seam the store implements. For one-shot reminders:
// pending → fired after successful delivery. a failed send does NOT mark the // pending → fired after successful delivery. For recurring reminders (with
// reminder fired (it stays pending; the next tick re-delivers). // 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 { type ReminderCompleter interface {
MarkReminder(ctx context.Context, id int64, status string) error 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 // 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 { if d.cfg.Reminders != nil && len(out) > 0 {
// ID=0 is a synthetic digest reminder; it's not in the DB so // 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. // were already marked fired by collapseReminders in gather.go.
if rd.Reminder.ID != 0 { if rd.Reminder.ID != 0 {
if err := d.cfg.Reminders.MarkReminder(ctx, rd.Reminder.ID, "fired"); err != nil { if rd.Reminder.Cron != "" {
return out, fmt.Errorf("mark reminder fired: %w", err) 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 id int64
status string status string
} }
err error rescheduled []int64
err error
} }
func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status string) 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 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 { type fakeAck struct {
acked map[string]bool acked map[string]bool
lastSent map[string]time.Time lastSent map[string]time.Time
@@ -599,3 +608,30 @@ func TestRepeatUnackedNilTelegramOrAckIsNoOp(t *testing.T) {
t.Fatalf("nil telegram/ack: want nil, got %+v", out) 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)
}
}
+10 -7
View File
@@ -51,13 +51,15 @@ type Note struct {
Score float64 `json:"score"` 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 { type Reminder struct {
ID int64 `json:"id"` ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"` CreatedTs time.Time `json:"created_ts"`
FireTs time.Time `json:"fire_ts"` FireTs time.Time `json:"fire_ts"`
Payload string `json:"payload"` NextFireTs time.Time `json:"next_fire_ts"`
Status string `json:"status"` // pending|fired|cancelled 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 // Presence — the read the phraser / delivery modules need to decide channel
@@ -137,6 +139,7 @@ type queryNotesReq struct {
type createReminderReq struct { type createReminderReq struct {
Fire time.Time `json:"fire"` Fire time.Time `json:"fire"`
Payload string `json:"payload"` Payload string `json:"payload"`
Cron string `json:"cron"`
} }
type recordNudgeReq struct { type recordNudgeReq struct {
Rule string `json:"rule"` Rule string `json:"rule"`
@@ -212,7 +215,7 @@ type CoreAPI interface {
LatestFactBySource(ctx context.Context, key, source string) (Fact, error) LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
Since(ctx context.Context, key string, now time.Time) (time.Duration, error) Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
Presence(ctx context.Context) (Presence, 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 MarkReminder(ctx context.Context, id int64, status string) error
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, 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 ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
+2 -2
View File
@@ -237,9 +237,9 @@ func (c *Client) Presence(ctx context.Context) (Presence, error) {
return p, nil 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 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 0, err
} }
return r.ID, nil return r.ID, nil
+1 -1
View File
@@ -311,7 +311,7 @@ func TestClient_E2E(t *testing.T) {
} }
// reminder lifecycle: create → mark fired → re-mark ⇒ ErrReminderState. // 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 { if err != nil {
t.Fatalf("CreateReminder: %v", err) t.Fatalf("CreateReminder: %v", err)
} }
+7 -3
View File
@@ -68,8 +68,8 @@ func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
} }
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) { func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
id, err := a.s.CreateReminder(ctx, fire, payload) id, err := a.s.CreateReminder(ctx, fire, payload, cron)
return id, mapErr(err) 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)) 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) { 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) id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
return id, mapErr(err) 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 { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err 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 return marshalResult(idResp{ID: id}), err
case MethodMarkReminder: case MethodMarkReminder:
+3 -1
View File
@@ -17,7 +17,9 @@ import (
// `ALTER TABLE ...;`, // #1 // `ALTER TABLE ...;`, // #1
// } // }
var migrations = []string{ 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 // migrate applies every migration with a number greater than the DB's current
+70 -18
View File
@@ -6,16 +6,19 @@ import (
"errors" "errors"
"fmt" "fmt"
"time" "time"
"github.com/robfig/cron/v3"
) )
// Reminder — user-stated future intent. fires once. relative→absolute happens // Reminder — user-stated future intent. fires once or recurring (if cron set).
// at capture ("in 4h" → store now+4h, never the string).
type Reminder struct { type Reminder struct {
ID int64 ID int64
CreatedTs time.Time CreatedTs time.Time
FireTs time.Time FireTs time.Time
Payload string // raw json NextFireTs time.Time // computed next fire (for recurring) or same as FireTs
Status string // pending | fired | cancelled Payload string // raw json
Status string // pending | fired | cancelled
Cron string // cron expression, empty for one-shot
} }
var ( var (
@@ -23,14 +26,35 @@ var (
ErrReminderState = errors.New("store: reminder not in a mutable state") 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. // CreateReminder persists a reminder with a resolved absolute fire time.
// The caller (router/capture path) MUST have already converted "in 4h" → now+4h. // The caller (router/capture path) MUST have already converted "in 4h" → now+4h.
// We do not accept strings here. // We do not accept strings here. cron is a cron expression for recurring
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) { // reminders; empty for one-shot.
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
now := time.Now().UTC() now := time.Now().UTC()
var cronPtr *string
if cron != "" {
cronPtr = &cron
}
res, err := s.db.ExecContext(ctx, res, err := s.db.ExecContext(ctx,
`INSERT INTO reminders (created_ts, fire_ts, payload, status) VALUES (?,?,?, 'pending')`, `INSERT INTO reminders (created_ts, fire_ts, next_fire_ts, payload, status, cron) VALUES (?,?,?,?, 'pending', ?)`,
now.UnixMilli(), fire.UnixMilli(), payload) now.UnixMilli(), fire.UnixMilli(), fire.UnixMilli(), payload, cronPtr)
if err != nil { if err != nil {
return 0, fmt.Errorf("create reminder: %w", err) 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 return id, nil
} }
// DueReminders returns pending reminders with fire_ts <= now, oldest first. // DueReminders returns pending reminders with next_fire_ts <= now, oldest first.
// This is the predicate input from the loop side: `fire_ts <= now AND status='pending'`. // 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) { func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, ` 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 FROM reminders
WHERE status = 'pending' AND fire_ts <= ? WHERE status = 'pending' AND next_fire_ts <= ?
ORDER BY fire_ts ASC`, now.UnixMilli()) ORDER BY next_fire_ts ASC`, now.UnixMilli())
if err != nil { if err != nil {
return nil, fmt.Errorf("due reminders: %w", err) return nil, fmt.Errorf("due reminders: %w", err)
} }
defer rows.Close() defer rows.Close()
var out []Reminder var out []Reminder
for rows.Next() { for rows.Next() {
var r Reminder r, err := scanReminder(rows)
var created, fire int64 if err != nil {
if err := rows.Scan(&r.ID, &created, &fire, &r.Payload, &r.Status); err != nil {
return nil, err return nil, err
} }
r.CreatedTs = time.UnixMilli(created).UTC()
r.FireTs = time.UnixMilli(fire).UTC()
out = append(out, r) out = append(out, r)
} }
return out, rows.Err() 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) _, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id)
return err 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_key_ts ON facts (key, ts DESC);
CREATE INDEX IF NOT EXISTS idx_facts_voids ON facts (voids_id); 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). -- relative→absolute happens at capture ("in 4h" → store now+4h, never the string).
CREATE TABLE IF NOT EXISTS reminders ( CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+48 -1
View File
@@ -166,7 +166,7 @@ func TestRemindersRelativeResolvedAtCapture(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// capture path (router) converts "in 4h" → absolute. store just takes fire_ts. // capture path (router) converts "in 4h" → absolute. store just takes fire_ts.
fire := time.Now().UTC().Add(4 * time.Hour) 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 { if err != nil {
t.Fatal(err) 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) { func TestNudgeOnceAndFeedbackOutcomes(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
ctx := context.Background() ctx := context.Background()