Day-of and crock-pot start-by reminders (Feature 20) (#177)
CI / ci (push) Successful in 8m35s
CI / ci (push) Successful in 8m35s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #177.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+21
-15
@@ -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 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, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||
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
|
||||
@@ -470,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user