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
+1
View File
@@ -234,6 +234,7 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
return "", err
}
s.notifier.Wake()
s.wake()
return planID, nil
}
+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
}
+3 -3
View File
@@ -146,7 +146,7 @@ func newEnv(t *testing.T) *env {
if err != nil {
t.Fatal(err)
}
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), publicURL, logger)
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, logger)
if err != nil {
t.Fatal(err)
}
@@ -428,8 +428,8 @@ func TestEmitDayOfReminder(t *testing.T) {
t.Fatalf("day_of_reminder rows = %+v, want one on push", rows)
}
r := rows[0]
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight's dinner" ||
r.Payload.Body != "Pad Thai" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight: Pad Thai · Recipe →" ||
r.Payload.Body != "" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
t.Errorf("day_of_reminder row = %+v", r)
}
}
+150
View File
@@ -0,0 +1,150 @@
package plan
import (
"context"
"errors"
"fmt"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// StartReminderLead is how long before a crock-pot dinner's start_by its
// start reminder fires.
const StartReminderLead = 15 * time.Minute
// StartReminderKey is the period key prefix of the crock-pot start
// reminder: its periods are start:YYYY-MM-DD of the day_of_reminder
// schedule.
const StartReminderKey = "start"
// ReminderSchedules are the day_of_reminder schedules. The first fires
// every day at the household's reminder_time, period YYYY-MM-DD, and emits
// day_of_reminder:YYYY-MM-DD for tonight's dinner (EmitDayOfReminder). The
// second fires StartReminderLead before the start_by of a crock-pot dinner
// whose start_by is earlier than that day's reminder_time, period
// start:YYYY-MM-DD of the dinner's date, and emits
// day_of_reminder:start:YYYY-MM-DD (EmitStartReminder). A date whose week
// has no plan emits neither.
func (s *Service) ReminderSchedules() []scheduler.Schedule {
return []scheduler.Schedule{
{
Name: scheduler.DayOfReminder,
Rule: func(settings household.Settings) scheduler.Rule {
return scheduler.Daily(settings.ReminderTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
sent, err := s.EmitDayOfReminder(ctx, p.Date)
if err != nil {
return err
}
if !sent {
s.logger.InfoContext(ctx, "day-of reminder not sent: the week has no plan",
"period", p.Key, "date", p.Date.String())
}
return nil
},
},
{
Name: scheduler.DayOfReminder,
Key: StartReminderKey,
Rule: func(household.Settings) scheduler.Rule {
return scheduler.DailyAt(s.startReminderAt)
},
Run: func(ctx context.Context, p scheduler.Period) error {
sent, err := s.EmitStartReminder(ctx, p.Date)
if err != nil {
return err
}
if !sent {
s.logger.InfoContext(ctx, "start reminder not sent: no crock-pot dinner to start before the reminder",
"period", p.Key, "date", p.Date.String())
}
return nil
},
},
}
}
// EmitStartReminder emits day_of_reminder:start:{date}, "Start the crock
// pot by {time}", for the crock-pot dinner planned on date, and reports
// whether it did: it emits nothing unless date's dinner has a start_by
// earlier than date's reminder_time. Emitting a date again stores no
// second notification.
func (s *Service) EmitStartReminder(ctx context.Context, date store.LocalDate) (bool, error) {
d, ok, err := s.startReminder(ctx, date)
if err != nil || !ok {
return false, err
}
if err := s.notifier.Emit(ctx, chat.StartReminder(date, d.Dinner.ID, d.Dinner.Name, *d.StartBy)); err != nil {
return false, fmt.Errorf("plan: %w", err)
}
return true, nil
}
// startReminderAt is the start reminder's scheduler.FireAt: the
// StartReminderLead before start_by of the dinner date's start reminder,
// if it has one.
func (s *Service) startReminderAt(ctx context.Context, date store.LocalDate) (time.Time, bool, error) {
d, ok, err := s.startReminder(ctx, date)
if err != nil || !ok {
return time.Time{}, false, err
}
return d.StartBy.Add(-StartReminderLead), true, nil
}
// startReminder returns the plan day of date when it has a start reminder:
// its dinner has a start_by (a crock-pot dinner of known minutes) earlier
// than the household's reminder_time on date. The plan and the settings
// come from one read transaction.
func (s *Service) startReminder(ctx context.Context, date store.LocalDate) (d Day, ok bool, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return Day{}, false, fmt.Errorf("plan: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
p, err := readPlan(ctx, q, household.WeekStart(date))
if errors.Is(err, ErrNotFound) {
return Day{}, false, nil
}
if err != nil {
return Day{}, false, err
}
d, err = p.day(date)
if err != nil {
return Day{}, false, err
}
if d.StartBy == nil {
return Day{}, false, nil
}
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return Day{}, false, fmt.Errorf("plan: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return Day{}, false, fmt.Errorf("plan: household settings: %w", err)
}
remind, err := household.LocalTime(zone, date, settings.ReminderTime)
if err != nil {
return Day{}, false, fmt.Errorf("plan: reminder time of %s: %w", date, err)
}
return d, d.StartBy.Before(remind), nil
}
// day returns p's day on date; a plan holds all seven days of its week.
func (p Plan) day(date store.LocalDate) (Day, error) {
for _, d := range p.Days {
if d.Date == date.String() {
return d, nil
}
}
return Day{}, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
}
+46 -5
View File
@@ -6,8 +6,11 @@ import (
"errors"
"fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -79,12 +82,40 @@ type Decided struct {
UpdatedBy string `json:"updated_by"`
}
// RotationCheckSchedule is the rotation_check schedule: every day at the
// household's review_time it runs MarkTried for the day before, so each
// Quest log dinner planned yesterday is tried and the household is asked
// "Keep it in rotation?". Its period is the fire date, claimed as
// rotation_check:YYYY-MM-DD. It never makes a dinner a Regular.
func (s *Service) RotationCheckSchedule() scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(settings household.Settings) scheduler.Rule {
return scheduler.Daily(settings.ReviewTime)
},
PeriodKey: func(p scheduler.Period) string {
return scheduler.RotationCheck + ":" + p.Key
},
Run: func(ctx context.Context, p scheduler.Period) error {
yesterday := addDays(p.Date, -1)
n, err := s.MarkTried(ctx, yesterday)
if err != nil {
return err
}
s.logger.InfoContext(ctx, "Rotation check: Quest log dinners marked tried",
"period", p.Key, "plan_date", yesterday.String(), "tried", n)
return nil
},
}
}
// MarkTried moves every dinner in the Quest log planned on date, the
// household-local day just past, on a plan of any status, to tried and
// opens a pending Rotation check of it for date. It is idempotent: a
// dinner already checked for date is left as it is, so a later run never
// moves one returned to the Quest log ("We didn't make it") again. It
// returns how many dinners it moved.
// household-local day just past, on a plan of any status, to tried, opens
// a pending Rotation check of it for date and emits its rotation_review
// (chat.RotationReview), all in one transaction. It is idempotent: a
// dinner already checked for date is left as it is and not notified of
// again, so a later run never moves one returned to the Quest log ("We
// didn't make it") again. It returns how many dinners it moved.
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
var n int
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -95,6 +126,10 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
}
now := store.Now().String()
for _, id := range ids {
d, err := q.GetDinner(ctx, id)
if err != nil {
return fmt.Errorf("plan: read dinner %s: %w", id, err)
}
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
if err != nil {
return fmt.Errorf("plan: mark %s tried: %w", id, err)
@@ -107,6 +142,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
}); err != nil {
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
}
if err := s.notifier.EmitTx(ctx, tx, chat.RotationReview(id, d.Name, date)); err != nil {
return fmt.Errorf("plan: %w", err)
}
}
n = len(ids)
return nil
@@ -114,6 +152,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
if err != nil {
return 0, err
}
if n > 0 {
s.notifier.Wake()
}
return n, nil
}
+42
View File
@@ -0,0 +1,42 @@
package plan
import (
"context"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
)
// DraftSchedule is the plan_draft schedule: at the household's
// plan_draft_dow and plan_draft_time it drafts the week after the fire
// date's, its period scheduler.WeeklyForNextWeek keys plan_draft:YYYY-Www
// for that week. The draft is the KindDraftWeek job a manual draft runs
// (StartScheduledDraft), so a stored draft emits plan_ready keyed
// plan_ready:{weekStart} and a draft that fails, as when the AI fails,
// stores no plan and emits nothing. A week that already has a plan is left
// as it is, and so is a draft of it already queued or running; either way
// the period says so.
func (s *Service) DraftSchedule() scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.PlanDraft,
Rule: func(settings household.Settings) scheduler.Rule {
return scheduler.WeeklyForNextWeek(time.Weekday(settings.PlanDraftDow), settings.PlanDraftTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
d, planned, err := s.StartScheduledDraft(ctx, p.Week)
if err != nil {
return err
}
switch {
case planned:
s.logger.InfoContext(ctx, "scheduled week draft not started: the week already has a plan",
"period", p.Key, "week_start", p.Week.String())
case !d.Created:
s.logger.WarnContext(ctx, "scheduled week draft not started: a draft of the week is already under way",
"period", p.Key, "week_start", p.Week.String(), "job_id", d.JobID)
}
return nil
},
}
}