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:
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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 "не получилось поставить напоминание."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
@@ -88,3 +109,34 @@ 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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user