From 54dc43516bdeb66427149e22b87a2160617d308f Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:41:49 +0400 Subject: [PATCH 1/2] Add accepted-routine timestamps to the store (Vikunja #366) Data layer only. Migration #8 adds accepted_ts and last_fired_ts to proposed_routines, plus ListAcceptedRoutines and MarkRoutineFired so the tick loop can own the schedule. Accepting no longer links a reminder id. Look at the TODO(vikunja#366) in cmd/mavend/tick.go for the next commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/tick.go | 4 ++ cmd/mavend/voice.go | 3 +- internal/store/migrations.go | 3 + internal/store/proposed_routines.go | 83 ++++++++++++++++++++---- internal/store/proposed_routines_test.go | 53 ++++++++++++--- 5 files changed, 124 insertions(+), 22 deletions(-) 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) } } From 424d1b344670aa9cc1458a271422f801fee36bfb Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 02:45:30 +0400 Subject: [PATCH 2/2] Fire accepted routines every interval, not once (Vikunja #366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tick loop now reads accepted routines from the store and nudges when their interval has passed; accepting no longer builds a one-shot reminder. Look at routine.DueAccepted for the schedule rule (no catch-up backlog) and at fireAcceptedRoutines for the restraint gate — routines do not bypass it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/tick.go | 64 +++++++++++++++++- cmd/mavend/tick_test.go | 109 +++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 20 ++---- internal/routine/routine.go | 41 ++++++++++++ internal/routine/routine_test.go | 34 ++++++++++ 5 files changed, 249 insertions(+), 19 deletions(-) diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index d591a4d..39d1c83 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -186,9 +186,9 @@ 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. + // accepted routines: patterns the user confirmed. read straight from the + // store each tick so the schedule survives a restart. + t.fireAcceptedRoutines(ctx, now, state) // morning routines: daily checklists (medicine/water/pets/...), nagged at // most once per day per routine, and only for items still unevidenced at @@ -381,6 +381,64 @@ func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.S } } +// fireAcceptedRoutines nudges about the routines the user accepted, once per +// interval (Vikunja #366). Accepting used to create a single reminder, so a +// non-weekly routine fired once and went quiet forever; the schedule lives in +// the proposed_routines row now and the loop re-reads it every tick. +// +// A routine is a care-class nudge and goes through the restraint gate like any +// other: quiet hours, away presence and snooze all suppress it. Reminders bypass +// that gate; routines must not. A suppressed nudge is NOT marked fired, so it +// goes out on the next tick that the gate allows — one nudge, held, not dropped +// and not repeated. +// +// The body is literal text built from the detected action and object, not +// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts. +func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) { + rows, err := t.store.ListAcceptedRoutines(ctx) + if err != nil { + log.Printf("tick: list accepted routines: %v", err) + return + } + accepted := make([]routine.Accepted, 0, len(rows)) + for _, r := range rows { + if r.AcceptedTs == nil { + continue // accepted before the schedule column existed — no clock to start from. + } + accepted = append(accepted, routine.Accepted{ + ID: r.ID, + Name: r.Action + " " + r.Object, + IntervalDays: r.IntervalDays, + Accepted: *r.AcceptedTs, + LastFired: r.LastFiredTs, + }) + } + + for _, a := range routine.DueAccepted(accepted, now) { + rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1} + if !loop.Gate(state, rule) { + continue + } + body := "пора: " + a.Name + pn := delivery.PhrasedNudge{ + Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state}, + Body: body, + Summary: body, + } + sent, err := t.dispatcher.DispatchNudge(ctx, pn, now) + if err != nil { + log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err) + continue + } + if len(sent) == 0 { + continue // routing dropped it — leave it due. + } + if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil { + log.Printf("tick: mark routine %d fired: %v", a.ID, err) + } + } +} + // fireMorningRoutines checks each configured checklist against today's facts // and dispatches a nag listing exactly what's still missing, at most once per // routine per calendar day. Fact reads happen here (not in loop.Gatherer) diff --git a/cmd/mavend/tick_test.go b/cmd/mavend/tick_test.go index 337ad3c..3333ce8 100644 --- a/cmd/mavend/tick_test.go +++ b/cmd/mavend/tick_test.go @@ -96,6 +96,115 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) { } } +// TestTickFiresAcceptedRoutineEveryInterval — Vikunja #366. An accepted routine +// with a 3-day interval must nudge every 3 days, not once. It also must not +// replay the occurrences it slept through: after a 30-day gap it nudges once. +func TestTickFiresAcceptedRoutineEveryInterval(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + accepted := refNow() + + id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted) + if err != nil { + t.Fatalf("CreateProposedRoutine: %v", err) + } + if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil { + t.Fatalf("AcceptProposedRoutine: %v", err) + } + + sink := &fakeSink{} + tl := newTestTickLoop(t, st, sink, nil) + const rule = "routine:полить цветы" + + // Same day as the accept: not due yet. + markPresent(t, st, ctx, accepted) + tl.tick(ctx, accepted.Add(time.Hour)) + if n := countSends(sink, rule); n != 0 { + t.Fatalf("routine fired %d times before its first interval passed, want 0", n) + } + + // Three days later: the first nudge. + first := accepted.Add(3 * 24 * time.Hour) + markPresent(t, st, ctx, first) + tl.tick(ctx, first) + if n := countSends(sink, rule); n != 1 { + t.Fatalf("first interval: sends = %d, want 1", n) + } + + // Next day: still inside the interval, silent. + sink.sends = nil + markPresent(t, st, ctx, first.Add(24*time.Hour)) + tl.tick(ctx, first.Add(24*time.Hour)) + if n := countSends(sink, rule); n != 0 { + t.Fatalf("mid-interval: sends = %d, want 0", n) + } + + // Three days after the first nudge: it fires again. This is the bug — + // a one-shot reminder would never come back. + second := first.Add(3 * 24 * time.Hour) + markPresent(t, st, ctx, second) + tl.tick(ctx, second) + if n := countSends(sink, rule); n != 1 { + t.Fatalf("second interval: sends = %d, want 1 (a routine repeats)", n) + } + + // A long silence must not turn into a backlog of missed nudges. + sink.sends = nil + late := second.Add(30 * 24 * time.Hour) + markPresent(t, st, ctx, late) + tl.tick(ctx, late) + if n := countSends(sink, rule); n != 1 { + t.Fatalf("after a 30-day gap: sends = %d, want exactly 1 (no backlog)", n) + } +} + +// TestTickAcceptedRoutineRespectsQuietHours — routines are not reminders: they +// do not inherit the reminder gate bypass. Away presence drops a care-class +// nudge, and the routine stays due so it nudges once the user is back. +func TestTickAcceptedRoutineRespectsGate(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + accepted := refNow() + + id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted) + if err != nil { + t.Fatalf("CreateProposedRoutine: %v", err) + } + if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil { + t.Fatalf("AcceptProposedRoutine: %v", err) + } + + sink := &fakeSink{} + tl := newTestTickLoop(t, st, sink, nil) + const rule = "routine:полить цветы" + + // No presence probes at all ⇒ away ⇒ the care gate blocks the nudge. + due := accepted.Add(3 * 24 * time.Hour) + tl.tick(ctx, due) + if n := countSends(sink, rule); n != 0 { + t.Fatalf("away: sends = %d, want 0 (routine must not bypass the gate)", n) + } + + // Back at the desk a minute later: the nudge that was held now goes out. + back := due.Add(time.Minute) + markPresent(t, st, ctx, back) + tl.tick(ctx, back) + if n := countSends(sink, rule); n != 1 { + t.Fatalf("present again: sends = %d, want 1", n) + } +} + +// countSends counts captured sends for one rule name. +func countSends(sink *fakeSink, rule string) int { + n := 0 + for _, s := range sink.sends { + if s.RuleName == rule { + n++ + } + } + return n +} + // refNow — fixed tick time so presence decay + since durations are deterministic. func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) } diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 679f05e..013bd91 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -1414,24 +1414,12 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri switch classifyConfirm(text) { case confirmYes: h.pendingRoutine = nil - // Create a recurring reminder at the detected interval. - // Weekly patterns get a cron expression; arbitrary intervals - // fire once and the detector re-proposes on the next cycle. - intervalDur := time.Duration(pr.interval * 24 * float64(time.Hour)) - fire := h.now().Add(intervalDur) - cron := "" - if pr.interval >= 6.5 && pr.interval <= 7.5 { - cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday())) - } - payload := fmt.Sprintf(`{"text":"%s %s"}`, pr.action, pr.object) - remID, err := h.api.CreateReminder(ctx, fire, payload, cron) - if err != nil { - log.Printf("voice: create routine reminder: %v", err) - return "не получилось поставить напоминание.", true - } - _ = remID // TODO(vikunja#366): stop creating a reminder here; the tick loop fires accepted routines. + // Only record the acceptance. The tick loop reads accepted + // routines and nudges on their own interval. Building a reminder + // here made a routine fire exactly once (Vikunja #366). if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil { log.Printf("voice: accept proposed routine: %v", err) + return "не получилось запомнить рутину.", true } return "буду напоминать.", true case confirmNo: diff --git a/internal/routine/routine.go b/internal/routine/routine.go index 7e883c4..3ee082e 100644 --- a/internal/routine/routine.go +++ b/internal/routine/routine.go @@ -55,6 +55,47 @@ func Validate(routines []Routine) error { return nil } +// Accepted — an accepted routine proposal as the tick driver sees it. This is a +// different shape from Routine: the schedule is a plain interval the pattern +// detector measured, not an operator-written cron expression. Accepted is when +// the human said yes; LastFired is nil until the first nudge. +type Accepted struct { + ID int64 + Name string + IntervalDays float64 + Accepted time.Time + LastFired *time.Time +} + +// DueAccepted returns the accepted routines whose interval has passed. It does +// not mutate anything — the caller persists the new last-fired time, because +// that has to survive a restart (unlike Due's in-memory map). +// +// The clock starts at LastFired, or at Accepted for a routine that has never +// nudged. A routine with a non-positive interval never fires: a bad interval +// should mean silence, not a nudge every tick. +// +// One occurrence per call, no catch-up: the caller stamps the fire time as now, +// so a routine that was silent for a month nudges once and then waits a full +// interval. Never a backlog. +func DueAccepted(rs []Accepted, now time.Time) []Accepted { + var out []Accepted + for _, r := range rs { + if r.IntervalDays <= 0 { + continue + } + since := r.Accepted + if r.LastFired != nil { + since = *r.LastFired + } + gap := time.Duration(r.IntervalDays * 24 * float64(time.Hour)) + if !now.Before(since.Add(gap)) { + out = append(out, r) + } + } + return out +} + // Due returns the routines whose schedule crossed since their last fire and // records now as the new last-fire time for each one returned. The caller owns // `last` (the tick driver holds it across ticks); Due mutates it in place. diff --git a/internal/routine/routine_test.go b/internal/routine/routine_test.go index 8e1550a..7ca89c5 100644 --- a/internal/routine/routine_test.go +++ b/internal/routine/routine_test.go @@ -5,6 +5,40 @@ import ( "time" ) +func TestDueAcceptedFiresOncePerInterval(t *testing.T) { + accepted := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC) + fired := accepted.Add(3 * 24 * time.Hour) + rs := []Accepted{ + {ID: 1, Name: "полить цветы", IntervalDays: 3, Accepted: accepted}, + {ID: 2, Name: "покормить рыб", IntervalDays: 3, Accepted: accepted, LastFired: &fired}, + {ID: 3, Name: "битый интервал", IntervalDays: 0, Accepted: accepted}, + } + + // One day in: nothing has waited a full interval. + if got := DueAccepted(rs, accepted.Add(24*time.Hour)); len(got) != 0 { + t.Fatalf("want nothing due after 1 day, got %+v", got) + } + + // Three days in: the never-fired one is due. The one that already fired at + // day 3 starts its next three days from there. A zero interval never fires. + got := DueAccepted(rs, fired) + if len(got) != 1 || got[0].ID != 1 { + t.Fatalf("want only routine 1 due at day 3, got %+v", got) + } + + // Six days in: both real routines are due. + if got := DueAccepted(rs, accepted.Add(6*24*time.Hour)); len(got) != 2 { + t.Fatalf("want both routines due at day 6, got %+v", got) + } + + // A month later the zero-interval routine is still silent. + for _, r := range DueAccepted(rs, accepted.Add(30*24*time.Hour)) { + if r.ID == 3 { + t.Fatal("a routine with a zero interval must never fire") + } + } +} + func TestValidate(t *testing.T) { ok := []Routine{{Name: "morning", Cron: "0 8 * * *", Body: "доброе утро"}} if err := Validate(ok); err != nil {