Feature 20: Scheduler and Weekly Automation (#182)
CI / ci (push) Successful in 8m28s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #182.
This commit is contained in:
2026-10-10 19:30:23 -04:00
committed by approver_agent
parent 4dcfc3c27d
commit ecf7c6be89
39 changed files with 4457 additions and 228 deletions
+46 -20
View File
@@ -141,6 +141,9 @@ type Service struct {
draft Drafter
cal Calendar
notifier *notify.Service
// wake is called after every committed change to a plan's days, which
// can move a crock-pot dinner's start reminder.
wake func()
logger *slog.Logger
draftJob *jobs.Kind[DraftJob]
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
@@ -150,11 +153,12 @@ type Service struct {
// NewService returns a service over st that drafts with draft, pushes to
// cal with links under publicURL, tells the household through notifier,
// which must be over st, and registers the KindDraftWeek job on
// which must be over st, calls wake (the scheduler's Wake) after every
// committed change to a plan's days, and registers the KindDraftWeek job on
// runner, which must not have started yet. The kind is service-only:
// StartDraft, which keeps one draft in flight per week, is the only way to
// enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
// StartDraft and StartScheduledDraft, which keep one draft in flight per
// week, are the only way to enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("plan: nil store")
@@ -166,12 +170,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
return nil, errors.New("plan: nil calendar")
case notifier == nil:
return nil, errors.New("plan: nil notifier")
case wake == nil:
return nil, errors.New("plan: nil wake")
case publicURL == nil:
return nil, errors.New("plan: nil public URL")
case logger == nil:
return nil, errors.New("plan: nil logger")
}
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, logger: logger, publicURL: publicURL}
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL}
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
if err != nil {
return nil, err
@@ -233,10 +239,10 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, e
}
// EmitDayOfReminder emits day_of_reminder for date, keyed by the date,
// naming the dinner planned on it, and reports whether it did: a date whose
// week has no plan emits nothing. The scheduler calls it at the
// household's reminder_time (Feature 20); emitting a date again stores no
// second notification.
// "Tonight: {dinner} · Recipe →" for the dinner planned on it, and reports
// whether it did: a date whose week has no plan emits nothing. The
// day_of_reminder schedule calls it at the household's reminder_time
// (ReminderSchedules); emitting a date again stores no second notification.
func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) {
p, err := s.Get(ctx, household.WeekStart(date))
if errors.Is(err, ErrNotFound) {
@@ -245,15 +251,14 @@ func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (
if err != nil {
return false, err
}
for _, d := range p.Days {
if d.Date == date.String() {
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
return false, fmt.Errorf("plan: %w", err)
}
return true, nil
}
d, err := p.day(date)
if err != nil {
return false, err
}
return false, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
return false, fmt.Errorf("plan: %w", err)
}
return true, nil
}
// readPlan reads the plan of the week starting weekStart through q, with
@@ -342,10 +347,27 @@ type Draft struct {
// enqueues nothing. Drafting over a synced or changed plan needs confirm,
// else it fails with ErrConfirmRequired and enqueues nothing.
func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) {
d, _, err := s.startDraft(ctx, weekStart, confirm, false)
return d, err
}
// StartScheduledDraft is StartDraft, without confirm, for the plan_draft
// schedule: it enqueues the same KindDraftWeek job, or returns the one
// queued or running, only while the week starting weekStart has no plan.
// A week that has a plan, in any status, is left as it is: nothing is
// enqueued and planned is true.
func (s *Service) StartScheduledDraft(ctx context.Context, weekStart store.LocalDate) (d Draft, planned bool, err error) {
return s.startDraft(ctx, weekStart, false, true)
}
// startDraft is StartDraft, and when onlyUnplanned is set it enqueues
// nothing for a week that has a plan and reports planned true.
func (s *Service) startDraft(ctx context.Context, weekStart store.LocalDate, confirm, onlyUnplanned bool) (Draft, bool, error) {
if _, err := household.WeekDays(weekStart); err != nil {
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
return Draft{}, false, &InvalidWeekError{Value: weekStart.String()}
}
var d Draft
planned := false
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetWeekPlan(ctx, weekStart.String())
@@ -353,6 +375,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
case onlyUnplanned:
planned = true
return nil
case cur.Status != StatusDraft && !confirm:
return ErrConfirmRequired
}
@@ -375,12 +400,12 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
return nil
})
if err != nil {
return Draft{}, err
return Draft{}, false, err
}
if d.Created {
s.draftJob.Wake()
}
return d, nil
return d, planned, nil
}
// Swap plans dinnerID on date of the week starting weekStart, marking the
@@ -450,6 +475,7 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
if err != nil {
return Plan{}, err
}
s.wake()
return p, nil
}