b3c2fad4ec
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.
207 lines
6.7 KiB
Go
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(¤t)
|
|
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
|
|
}
|