Files
Maven/internal/store/reminders.go
T
kami b3c2fad4ec morning: recite the day the store actually holds
Four defects in the plan, all of them in what it reads or how it prints
it. The checklist line was keyed on Status.Active, which Evaluate reports
only inside the window, so a morning routine skipped and asked about at
14:00 said nothing. Outstanding answers the question the plan asks, "what
did today still not get done", and the line stays placed at the nudge
time so it sorts to the top of the day. Nothing before the window opens
counts, so 06:00 is not a complaint.

The event text kept the "@ 14:00-14:30" tail FactValue writes, next to a
line that prints the hour itself, so every event said its time twice.
Reminders came off ListReminders, which orders by creation, so the 500
row cap dropped a reminder stated long ago for today and kept one stated
this morning for next year. PendingReminders bounds by fire time instead.
The pending filter used a string literal, one typo from matching nothing.

After now marks the plan it trimmed. "что дальше?" past the last item
answered "на 03.08.2026 ничего не запланировано", which denies a day he
just lived through.

The surface the plan belongs on is still open, tracked as Vikunja #431;
the comment in actions_query.go points at it.
Found in review of #58.
2026-08-01 14:07:43 +04:00

207 lines
6.7 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/robfig/cron/v3"
)
// Reminder — user-stated future intent. fires once or recurring (if cron set).
type Reminder struct {
ID int64
CreatedTs time.Time
FireTs time.Time
NextFireTs time.Time // computed next fire (for recurring) or same as FireTs
Payload string // raw json
Status string // pending | fired | cancelled
Cron string // cron expression, empty for one-shot
// Collapsed — set only on a synthetic digest reminder (ID=0): the original
// due reminders it stands in for. Not persisted. The dispatcher completes
// (marks fired / reschedules) each of these after the digest delivers.
Collapsed []Reminder
}
// Reminder lifecycle states. Named for the same reason DigestStatus is: a
// caller filtering on the string literal "pending" is one typo away from a
// filter that silently matches nothing.
const (
ReminderPending = "pending"
ReminderFired = "fired"
ReminderCancelled = "cancelled"
)
var (
ErrReminderNotFound = errors.New("store: reminder not found")
ErrReminderState = errors.New("store: reminder not in a mutable state")
)
func scanReminder(sc scanner) (Reminder, error) {
var r Reminder
var created, fire, nextFire int64
var cron *string
if err := sc.Scan(&r.ID, &created, &fire, &nextFire, &r.Payload, &r.Status, &cron); err != nil {
return Reminder{}, err
}
r.CreatedTs = time.UnixMilli(created).UTC()
r.FireTs = time.UnixMilli(fire).UTC()
r.NextFireTs = time.UnixMilli(nextFire).UTC()
if cron != nil {
r.Cron = *cron
}
return r, nil
}
// CreateReminder persists a reminder with a resolved absolute fire time.
// The caller (router/capture path) MUST have already converted "in 4h" → now+4h.
// We do not accept strings here. cron is a cron expression for recurring
// reminders; empty for one-shot.
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
now := time.Now().UTC()
var cronPtr *string
if cron != "" {
cronPtr = &cron
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO reminders (created_ts, fire_ts, next_fire_ts, payload, status, cron) VALUES (?,?,?,?, 'pending', ?)`,
now.UnixMilli(), fire.UnixMilli(), fire.UnixMilli(), payload, cronPtr)
if err != nil {
return 0, fmt.Errorf("create reminder: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("create reminder: last insert id: %w", err)
}
return id, nil
}
// DueReminders returns pending reminders with next_fire_ts <= now, oldest first.
// This is the predicate input from the loop side: `next_fire_ts <= now AND status='pending'`.
func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders
WHERE status = 'pending' AND next_fire_ts <= ?
ORDER BY next_fire_ts ASC`, now.UnixMilli())
if err != nil {
return nil, fmt.Errorf("due reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// PendingReminders returns the pending reminders whose next fire time falls in
// [from, to), earliest first.
//
// The day plan used to take the newest 500 rows out of ListReminders, which
// orders by creation, and then filter them by day. A reminder stated long ago
// for today fell off the end of that scan while a reminder stated this morning
// for next year stayed on it. Bounding by fire time drops what is out of range
// instead of what is old.
func (s *Store) PendingReminders(ctx context.Context, from, to time.Time) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders
WHERE status = ? AND next_fire_ts >= ? AND next_fire_ts < ?
ORDER BY next_fire_ts ASC, id ASC`,
ReminderPending, from.UnixMilli(), to.UnixMilli())
if err != nil {
return nil, fmt.Errorf("pending reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// MarkReminder sets a reminder's status. Only valid transitions: pending→fired,
// pending→cancelled. Anything else is a programming error.
func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error {
if status != "fired" && status != "cancelled" {
return fmt.Errorf("%w: %s", ErrReminderState, status)
}
// pending → fired|cancelled only.
var current string
err := s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(&current)
if errors.Is(err, sql.ErrNoRows) {
return ErrReminderNotFound
}
if err != nil {
return err
}
if current != "pending" {
return fmt.Errorf("%w: currently %s", ErrReminderState, current)
}
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id)
return err
}
// ListReminders returns the n most recent reminders, newest first.
func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders ORDER BY created_ts DESC, id DESC LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("list reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.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
}