diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index 67835f5..d591a4d 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -186,6 +186,10 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) { // LLM-phrased — so a routine can't hallucinate. severity comes from config. t.fireRoutines(ctx, now, state) + // TODO(vikunja#366): fire accepted routines here — read + // store.ListAcceptedRoutines, pick the ones whose interval has passed, nudge + // them through the gate, then MarkRoutineFired. + // morning routines: daily checklists (medicine/water/pets/...), nagged at // most once per day per routine, and only for items still unevidenced at // nudge time. See internal/morning for the "why not four timers" rationale. diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 9bbd226..679f05e 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -1429,7 +1429,8 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri log.Printf("voice: create routine reminder: %v", err) return "не получилось поставить напоминание.", true } - if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, remID); err != nil { + _ = remID // TODO(vikunja#366): stop creating a reminder here; the tick loop fires accepted routines. + if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil { log.Printf("voice: accept proposed routine: %v", err) } return "буду напоминать.", true diff --git a/internal/store/migrations.go b/internal/store/migrations.go index c19d52f..b0d8f01 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -70,6 +70,9 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2 CHECK (resolution_state IN ('none','pending','resolved','ambiguous','not_found')); CREATE INDEX IF NOT EXISTS idx_facts_entity_id ON facts (entity_id) WHERE entity_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async + + `ALTER TABLE proposed_routines ADD COLUMN accepted_ts INTEGER; + ALTER TABLE proposed_routines ADD COLUMN last_fired_ts INTEGER;`, // #8 — accepted routines keep firing (Vikunja #366): the tick loop needs to know when a routine was accepted and when it last nudged } // migrate applies every migration with a number greater than the DB's current diff --git a/internal/store/proposed_routines.go b/internal/store/proposed_routines.go index 6233817..b19ca78 100644 --- a/internal/store/proposed_routines.go +++ b/internal/store/proposed_routines.go @@ -8,10 +8,15 @@ import ( "time" ) -// ProposedRoutine — a detected pattern the system wants to turn into a -// recurring reminder. Status 'proposed' means awaiting human confirmation; -// 'accepted' means the human confirmed and a reminder was created (reminder_id -// set); 'dismissed' means the human declined and we won't re-propose. +// ProposedRoutine — a detected pattern the system wants to nudge about on a +// repeating interval. Status 'proposed' means awaiting human confirmation; +// 'accepted' means the human confirmed and the tick loop now owns the schedule; +// 'dismissed' means the human declined and we won't re-propose. +// +// AcceptedTs is when the human said yes; it is the clock start for the first +// nudge. LastFiredTs is when the last nudge went out, nil until the first one. +// ReminderID is only set on rows accepted before Vikunja #366, when accepting +// created a one-shot reminder instead. type ProposedRoutine struct { ID int64 Action string @@ -19,7 +24,9 @@ type ProposedRoutine struct { IntervalDays float64 Status string // proposed | accepted | dismissed CreatedTs time.Time - ReminderID *int64 // set when accepted + ReminderID *int64 + AcceptedTs *time.Time + LastFiredTs *time.Time } var ( @@ -57,7 +64,7 @@ func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string // nil (no error) when no row exists. func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string) (*ProposedRoutine, error) { row := s.db.QueryRowContext(ctx, ` - SELECT id, action, object, interval_days, status, created_ts, reminder_id + SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts FROM proposed_routines WHERE action = ? AND object = ?`, action, object) r, err := scanProposedRoutine(row) @@ -74,7 +81,7 @@ func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string // newest first. func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT id, action, object, interval_days, status, created_ts, reminder_id + SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts FROM proposed_routines WHERE status = 'proposed' ORDER BY created_ts DESC, id DESC`) @@ -93,12 +100,14 @@ func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, er return out, rows.Err() } -// AcceptProposedRoutine flips status to 'accepted', links a reminder_id. -// Returns error if not in 'proposed' status. -func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error { +// AcceptProposedRoutine flips status to 'accepted' and records when. From that +// timestamp the tick loop owns the schedule: it re-reads accepted rows every +// tick and nudges when the interval has passed. Returns an error if the row is +// not in 'proposed' status. +func (s *Store) AcceptProposedRoutine(ctx context.Context, id int64, ts time.Time) error { res, err := s.db.ExecContext(ctx, - `UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`, - reminderID, id) + `UPDATE proposed_routines SET status = 'accepted', accepted_ts = ? WHERE id = ? AND status = 'proposed'`, + ts.UnixMilli(), id) if err != nil { return fmt.Errorf("accept proposed routine: %w", err) } @@ -109,6 +118,42 @@ func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) return nil } +// ListAcceptedRoutines returns every accepted routine, oldest first. The tick +// loop reads this each tick and decides which ones are due. +func (s *Store) ListAcceptedRoutines(ctx context.Context) ([]ProposedRoutine, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts + FROM proposed_routines + WHERE status = 'accepted' + ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("list accepted routines: %w", err) + } + defer rows.Close() + var out []ProposedRoutine + for rows.Next() { + r, err := scanProposedRoutine(rows) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// MarkRoutineFired records that a routine just nudged. The stored time is the +// nudge time, not the time it was theoretically due, so a routine that was +// silent for a while starts its next interval from now — missed occurrences are +// dropped, never replayed as a backlog. +func (s *Store) MarkRoutineFired(ctx context.Context, id int64, ts time.Time) error { + if _, err := s.db.ExecContext(ctx, + `UPDATE proposed_routines SET last_fired_ts = ? WHERE id = ?`, + ts.UnixMilli(), id); err != nil { + return fmt.Errorf("mark routine fired: %w", err) + } + return nil +} + // DismissProposedRoutine flips status to 'dismissed'. Idempotent. func (s *Store) DismissProposedRoutine(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, @@ -125,12 +170,24 @@ func scanProposedRoutine(sc scanner) (ProposedRoutine, error) { var r ProposedRoutine var created int64 var reminderID sql.NullInt64 - if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID); err != nil { + var accepted, lastFired sql.NullInt64 + if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID, &accepted, &lastFired); err != nil { return ProposedRoutine{}, err } r.CreatedTs = time.UnixMilli(created).UTC() if reminderID.Valid { r.ReminderID = &reminderID.Int64 } + r.AcceptedTs = millisToTime(accepted) + r.LastFiredTs = millisToTime(lastFired) return r, nil } + +// millisToTime turns a nullable unix-millis column into a *time.Time. +func millisToTime(v sql.NullInt64) *time.Time { + if !v.Valid { + return nil + } + t := time.UnixMilli(v.Int64).UTC() + return &t +} diff --git a/internal/store/proposed_routines_test.go b/internal/store/proposed_routines_test.go index fe29c97..030f60d 100644 --- a/internal/store/proposed_routines_test.go +++ b/internal/store/proposed_routines_test.go @@ -43,12 +43,7 @@ func TestCreateAndAcceptProposedRoutine(t *testing.T) { } // Accept - // First create a reminder to link - remID, err := s.CreateReminder(ctx, now.Add(7*24*time.Hour), `{"text":"refill cat water"}`, "0 10 * * 0") - if err != nil { - t.Fatalf("CreateReminder: %v", err) - } - if err := s.AcceptProposedRoutine(ctx, id, remID); err != nil { + if err := s.AcceptProposedRoutine(ctx, id, now); err != nil { t.Fatalf("AcceptProposedRoutine: %v", err) } @@ -60,8 +55,50 @@ func TestCreateAndAcceptProposedRoutine(t *testing.T) { if r.Status != "accepted" { t.Fatalf("want status=accepted, got %s", r.Status) } - if r.ReminderID == nil || *r.ReminderID != remID { - t.Fatalf("want reminder_id=%d, got %v", remID, r.ReminderID) + if r.AcceptedTs == nil || !r.AcceptedTs.Equal(now.Truncate(time.Millisecond)) { + t.Fatalf("want accepted_ts=%v, got %v", now, r.AcceptedTs) + } + if r.LastFiredTs != nil { + t.Fatalf("a freshly accepted routine has not fired yet, got %v", r.LastFiredTs) + } +} + +// TestAcceptedRoutineFiredTimestamp — the tick loop's two reads: the accepted +// list, and the last-fired stamp it writes back after a nudge. +func TestAcceptedRoutineFiredTimestamp(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Millisecond) + + id, err := s.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, now) + if err != nil { + t.Fatalf("CreateProposedRoutine: %v", err) + } + if err := s.AcceptProposedRoutine(ctx, id, now); err != nil { + t.Fatalf("AcceptProposedRoutine: %v", err) + } + + list, err := s.ListAcceptedRoutines(ctx) + if err != nil { + t.Fatalf("ListAcceptedRoutines: %v", err) + } + if len(list) != 1 || list[0].ID != id { + t.Fatalf("want the one accepted routine, got %+v", list) + } + if list[0].IntervalDays != 3.0 { + t.Fatalf("want interval_days=3, got %v", list[0].IntervalDays) + } + + fired := now.Add(3 * 24 * time.Hour) + if err := s.MarkRoutineFired(ctx, id, fired); err != nil { + t.Fatalf("MarkRoutineFired: %v", err) + } + list, err = s.ListAcceptedRoutines(ctx) + if err != nil { + t.Fatalf("ListAcceptedRoutines: %v", err) + } + if list[0].LastFiredTs == nil || !list[0].LastFiredTs.Equal(fired) { + t.Fatalf("want last_fired_ts=%v, got %v", fired, list[0].LastFiredTs) } }