Add day-of and crock-pot start-by reminders (Feature 20)
Register plan.Service.ReminderSchedules as the day_of_reminder schedule:
daily at the household reminder_time (period YYYY-MM-DD) it emits
day_of_reminder:YYYY-MM-DD, "Tonight: {dinner} · Recipe →" linking
/dinner/{id}; for a crock-pot dinner whose start_by is earlier than
reminder_time it also fires 15 minutes before start_by (period
start:YYYY-MM-DD) and emits day_of_reminder:start:YYYY-MM-DD, "Start the
crock pot by {time}". A date whose week has no plan emits nothing.
The scheduler gains DailyAt rules, whose fire time per date is looked up
from data on every pass (a failed lookup is retried every ClaimRetry),
and a Schedule Key that prefixes period keys so two schedules of one
name claim different periods.
Maestro task 30963b62-2803-4879-82ff-c351cc6628cf.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -198,6 +198,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
if err := sched.Register(planSvc.DraftSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sch := range planSvc.ReminderSchedules() {
|
||||
if err := sched.Register(sch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
|
||||
+24
-5
@@ -782,7 +782,7 @@ purge are fixed and not configurable.
|
||||
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
|
||||
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
|
||||
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
|
||||
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
|
||||
| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). |
|
||||
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
|
||||
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. |
|
||||
| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). |
|
||||
@@ -790,7 +790,7 @@ purge are fixed and not configurable.
|
||||
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
|
||||
`internal/scheduler` runs them. A schedule registers a name, a rule read
|
||||
from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`) and a
|
||||
from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`, or `DailyAt` for a time read from data) and a
|
||||
run function; settings changes reach it through `SettingsService.Subscribe`
|
||||
and move the next fire time without a restart. Each fire time has a period
|
||||
key (`Rule.PeriodKey`): the local date for a daily rule, the ISO week of the
|
||||
@@ -822,6 +822,19 @@ draft emits `plan_ready:{weekStart}` from the job and a failed one (e.g.
|
||||
`ai_failed`, recorded in `ai_runs`) emits nothing. When the week already
|
||||
has a plan, in any status, it enqueues nothing; when a draft of the week
|
||||
is already queued or running it joins it. Either way it logs it.
|
||||
`plan.Service.ReminderSchedules()` is `day_of_reminder`, two schedules
|
||||
of one name. The first is `Daily(reminder_time)`, period `YYYY-MM-DD`,
|
||||
and calls `EmitDayOfReminder(period.Date)`. The second has `Key` `start`,
|
||||
so its periods are `start:YYYY-MM-DD`, and a `scheduler.DailyAt` rule:
|
||||
for each date it reads the plan and fires 15 minutes
|
||||
(`plan.StartReminderLead`) before the day's `start_by` (the same
|
||||
computation the plan API shows) when the dinner is a crock-pot dinner
|
||||
whose `start_by` is earlier than that day's `reminder_time`; its run
|
||||
calls `EmitStartReminder(period.Date)`, which checks the same again. A
|
||||
date whose week has no plan emits neither and logs it. A `DailyAt` fire
|
||||
time is looked up from the data on every pass, so a plan change moves it
|
||||
within an hour (`maxWait`); a lookup that fails is logged and repeated
|
||||
every `ClaimRetry`.
|
||||
Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
`SetClock` is visible to tests.
|
||||
|
||||
@@ -857,9 +870,15 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
notifies. This is the only place `new_suggestions` is emitted; the
|
||||
`suggestions_weekly` schedule does not emit it itself.
|
||||
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through
|
||||
`plan.Service.EmitDayOfReminder(date)`, which names the dinner planned
|
||||
on the date and emits nothing for a week with no plan. The scheduler
|
||||
calls it at `reminder_time` (Feature 20).
|
||||
`plan.Service.EmitDayOfReminder(date)`, "Tonight: {dinner} · Recipe →",
|
||||
naming the dinner planned on the date; it emits nothing for a week
|
||||
with no plan. The `day_of_reminder` schedule calls it at
|
||||
`reminder_time`.
|
||||
- `day_of_reminder:start:{date}` (URL `/dinner/{id}`) through
|
||||
`plan.Service.EmitStartReminder(date)`, "Start the crock pot by
|
||||
{time}" with the dinner's name, only for a crock-pot dinner whose
|
||||
`start_by` is earlier than `reminder_time`. The `day_of_reminder`
|
||||
schedule calls it 15 minutes before `start_by`.
|
||||
- Delivery goes through `notification_outbox`.
|
||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||
webhook.
|
||||
|
||||
@@ -65,12 +65,27 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
|
||||
}
|
||||
|
||||
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
|
||||
// name, planned on date.
|
||||
// name, planned on date: "Tonight: Tacos · Recipe →", a title carrying its
|
||||
// own call to action.
|
||||
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.DayOfReminder,
|
||||
Key: string(notify.DayOfReminder) + ":" + date.String(),
|
||||
Title: "Tonight's dinner",
|
||||
Title: "Tonight: " + name + " · Recipe →",
|
||||
URL: "/dinner/" + dinnerID,
|
||||
Week: household.WeekStart(date),
|
||||
}
|
||||
}
|
||||
|
||||
// StartReminder is the day_of_reminder event, keyed
|
||||
// day_of_reminder:start:{date}, reminding the household to start the
|
||||
// crock-pot dinner dinnerID, named name and planned on date, by startBy,
|
||||
// a time in the household zone: "Start the crock pot by 9:30 am".
|
||||
func StartReminder(date store.LocalDate, dinnerID, name string, startBy time.Time) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.DayOfReminder,
|
||||
Key: string(notify.DayOfReminder) + ":start:" + date.String(),
|
||||
Title: "Start the crock pot by " + startBy.Format("3:04 pm"),
|
||||
Body: name,
|
||||
URL: "/dinner/" + dinnerID,
|
||||
Week: household.WeekStart(date),
|
||||
@@ -173,9 +188,13 @@ func planTitle(week, thisWeek store.LocalDate) string {
|
||||
}
|
||||
|
||||
// Text is the Google Chat message of p: its title, its body after a colon
|
||||
// and the event's call to action with an arrow.
|
||||
// and the event's call to action with an arrow. A title ending in an arrow,
|
||||
// with no body, carries its own call to action and is the message as it is.
|
||||
func Text(p notify.Payload) string {
|
||||
text := p.Title
|
||||
if p.Body == "" && strings.HasSuffix(text, "→") {
|
||||
return text
|
||||
}
|
||||
if p.Body != "" {
|
||||
if endsSentence(text) {
|
||||
text += " "
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
@@ -100,9 +101,19 @@ func TestMessages(t *testing.T) {
|
||||
key: "day_of_reminder:2026-10-15",
|
||||
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
||||
week: "2026-10-12",
|
||||
title: "Tonight's dinner",
|
||||
body: "Tacos",
|
||||
chatT: "Tonight's dinner: Tacos. Open the recipe →",
|
||||
title: "Tonight: Tacos · Recipe →",
|
||||
body: "",
|
||||
chatT: "Tonight: Tacos · Recipe →",
|
||||
},
|
||||
{
|
||||
name: "crock-pot start reminder",
|
||||
ev: StartReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Beef Chili", time.Date(2026, 10, 15, 9, 30, 0, 0, time.FixedZone("CDT", -5*60*60))),
|
||||
key: "day_of_reminder:start:2026-10-15",
|
||||
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
||||
week: "2026-10-12",
|
||||
title: "Start the crock pot by 9:30 am",
|
||||
body: "Beef Chili",
|
||||
chatT: "Start the crock pot by 9:30 am: Beef Chili. Open the recipe →",
|
||||
},
|
||||
{
|
||||
name: "new suggestions",
|
||||
|
||||
+11
-12
@@ -233,10 +233,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 +245,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// The tests in this file run plan's day_of_reminder schedules on the fake
|
||||
// clock against the real plan service and notification outbox. The plan of
|
||||
// the week of Monday Oct 12 (w42) has poolNames Monday to Sunday: Beef
|
||||
// Chili on Monday, Pot Roast on Tuesday. The household's dinner time is the
|
||||
// seeded 17:30 and its reminder time the seeded 16:30, in Chicago.
|
||||
|
||||
// w42 is the Monday of 2026-W42.
|
||||
var w42 = mustLocalDate("2026-10-12")
|
||||
|
||||
// planWeek drafts the plan of the week starting weekStart and waits for it.
|
||||
func (p *planEnv) planWeek(weekStart store.LocalDate) {
|
||||
p.e.t.Helper()
|
||||
p.reply(weekStart)
|
||||
if _, err := p.svc.StartDraft(context.Background(), weekStart, false); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
if jobs := p.e.drafts(); jobs[len(jobs)-1] != succeeded {
|
||||
p.e.t.Fatalf("draft of %s = %+v, want succeeded", weekStart, jobs)
|
||||
}
|
||||
}
|
||||
|
||||
// crockPot makes the dinner name a crock-pot dinner of minutes.
|
||||
func (p *planEnv) crockPot(name string, minutes int) {
|
||||
p.e.t.Helper()
|
||||
if _, err := p.e.st.Writer.Exec(`UPDATE dinners SET method = 'crockpot', minutes_total = ? WHERE id = ?`, minutes, p.ids[name]); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// reminders returns the day_of_reminder notifications stored, oldest
|
||||
// first. The seeded preference sends day_of_reminder to push only, so there
|
||||
// is one row per event.
|
||||
func (e *env) reminders() []notifytest.Row {
|
||||
e.t.Helper()
|
||||
rows := notifytest.Rows(e.t, e.st, notify.DayOfReminder)
|
||||
for _, r := range rows {
|
||||
if r.Channel != notify.ChannelPush {
|
||||
e.t.Fatalf("day_of_reminder row %+v, want push only", r)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// reminderKeys returns the keys of e.reminders().
|
||||
func (e *env) reminderKeys() []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
for _, r := range e.reminders() {
|
||||
keys = append(keys, r.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// wantReminder fails unless the reminder keyed key was stored with title,
|
||||
// body and the link to the dinner name.
|
||||
func (p *planEnv) wantReminder(key, title, body, name string) {
|
||||
p.e.t.Helper()
|
||||
for _, r := range p.e.reminders() {
|
||||
if r.Key != key {
|
||||
continue
|
||||
}
|
||||
week := household.WeekStart(mustLocalDate(key[len(key)-len("YYYY-MM-DD"):]))
|
||||
want := notify.Payload{Event: notify.DayOfReminder, Title: title, Body: body, URL: "/dinner/" + p.ids[name], Tag: key, Week: week.String()}
|
||||
if r.Payload != want {
|
||||
p.e.t.Errorf("reminder %s = %+v, want %+v", key, r.Payload, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
p.e.t.Errorf("no reminder %s", key)
|
||||
}
|
||||
|
||||
// TestDayOfReminderNormalNight: a night whose dinner is not a crock-pot
|
||||
// dinner gets exactly the daily reminder, at reminder_time 16:30, keyed
|
||||
// day_of_reminder:YYYY-MM-DD and linking to the dinner.
|
||||
func TestDayOfReminderNormalNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.planWeek(w42)
|
||||
|
||||
// Monday 08:00: the catch-up of Sunday 16:30 finds no plan for W41.
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 16, 29))
|
||||
wantKeys(t, "before 16:30", e.reminderKeys(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 16, 30))
|
||||
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||
p.wantReminder("day_of_reminder:2026-10-13", "Tonight: Pot Roast · Recipe →", "", "Pot Roast")
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderCrockPotNight: a crock-pot night whose start_by is
|
||||
// earlier than reminder_time gets the start reminder at start_by minus 15
|
||||
// minutes, keyed day_of_reminder:start:YYYY-MM-DD, and the daily reminder
|
||||
// at reminder_time. A crock-pot night whose start_by is not earlier than
|
||||
// reminder_time gets only the daily one.
|
||||
func TestDayOfReminderCrockPotNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.crockPot("Beef Chili", 480) // Monday: start by 09:30
|
||||
p.crockPot("Pot Roast", 30) // Tuesday: start by 17:00, after 16:30
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 9, 14))
|
||||
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 16, 29))
|
||||
wantKeys(t, "before 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r.runUntil(at(2026, 10, 12, 16, 30))
|
||||
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderEmptyNight: with no plan for tonight's week, here none
|
||||
// at all and then only next week's, nothing is emitted and nothing fails;
|
||||
// next week's crock-pot Monday is reminded on its own day.
|
||||
func TestDayOfReminderEmptyNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "with no plan", e.reminderKeys(), nil)
|
||||
|
||||
p.crockPot("Beef Chili", 480)
|
||||
p.planWeek(w43)
|
||||
r.runUntil(at(2026, 10, 18, 23, 59))
|
||||
wantKeys(t, "with only next week's plan", e.reminderKeys(), nil)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder),
|
||||
[]string{"2026-10-11", "2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"})
|
||||
if logs := p.logs.String(); !strings.Contains(logs, "day-of reminder not sent: the week has no plan") || strings.Contains(logs, "level=ERROR") {
|
||||
t.Errorf("logs = %s, want the empty nights logged and no error", logs)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 19, 9, 15))
|
||||
wantKeys(t, "next Monday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-19"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-19", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
|
||||
}
|
||||
|
||||
// TestDayOfReminderFollowsReminderTimeWithoutRestart: a reminder_time
|
||||
// change takes effect in the running scheduler. Moved from 16:30 to 18:00,
|
||||
// the daily reminder fires at 18:00, not 16:30, and a crock-pot start_by
|
||||
// of 17:00, now earlier than reminder_time, gets its start reminder at
|
||||
// 16:45.
|
||||
func TestDayOfReminderFollowsReminderTimeWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.crockPot("Beef Chili", 30) // start by 17:00
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("18:00")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 12, 16, 44))
|
||||
wantKeys(t, "before 16:45", e.reminderKeys(), nil)
|
||||
r.runUntil(at(2026, 10, 12, 16, 45))
|
||||
wantKeys(t, "at 16:45", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 5:00 pm", "Beef Chili", "Beef Chili")
|
||||
r.runUntil(at(2026, 10, 12, 17, 59))
|
||||
wantKeys(t, "before 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r.runUntil(at(2026, 10, 12, 18, 0))
|
||||
wantKeys(t, "at 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderNoDuplicateAfterRestart: restarts after each reminder
|
||||
// of a crock-pot night, within the catch-up window, emit neither again:
|
||||
// each period is already claimed.
|
||||
func TestDayOfReminderNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p.crockPot("Beef Chili", 480)
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
p2 := e.plans(ids)
|
||||
r2 := e.start(at(2026, 10, 12, 9, 20), p2.svc.ReminderSchedules()...)
|
||||
wantKeys(t, "after the first restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r2.runUntil(at(2026, 10, 12, 16, 30))
|
||||
r2.stop()
|
||||
p2.stop()
|
||||
|
||||
p3 := e.plans(ids)
|
||||
r3 := e.start(at(2026, 10, 12, 16, 40), p3.svc.ReminderSchedules()...)
|
||||
r3.runUntil(at(2026, 10, 13, 9, 0))
|
||||
wantKeys(t, "after the second restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "start:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDailyAtLookupFailureIsRetried: a DailyAt rule fires at the instant
|
||||
// its lookup gives, its period keyed by the schedule's Key and the date;
|
||||
// a lookup that fails fires nothing and is looked up again after
|
||||
// ClaimRetry, then fires its period once.
|
||||
func TestDailyAtLookupFailureIsRetried(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
var mu sync.Mutex
|
||||
failing := false
|
||||
at915 := func(ctx context.Context, d store.LocalDate) (time.Time, bool, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if failing {
|
||||
return time.Time{}, false, errors.New("database down")
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
return at(y, m, day, 9, 15), true, nil
|
||||
}
|
||||
sch := scheduler.Schedule{
|
||||
Name: scheduler.DayOfReminder,
|
||||
Key: "start",
|
||||
Rule: func(household.Settings) scheduler.Rule { return scheduler.DailyAt(at915) },
|
||||
Run: rec.run,
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), sch)
|
||||
wantKeys(t, "catch-up", rec.keys(), []string{"start:2026-10-11"})
|
||||
mu.Lock()
|
||||
failing = true
|
||||
mu.Unlock()
|
||||
r.runUntil(at(2026, 10, 12, 9, 16))
|
||||
wantKeys(t, "while the lookup fails", rec.keys(), []string{"start:2026-10-11"})
|
||||
|
||||
mu.Lock()
|
||||
failing = false
|
||||
mu.Unlock()
|
||||
r.runUntil(at(2026, 10, 12, 9, 17))
|
||||
wantKeys(t, "after the lookup recovers", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12"})
|
||||
if p := rec.periods()[1]; !p.FireAt.Equal(at(2026, 10, 12, 9, 15)) || p.Date.String() != "2026-10-12" {
|
||||
t.Errorf("period = %+v, want fired at 09:15 for 2026-10-12", p)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 13, 12, 0))
|
||||
wantKeys(t, "next day", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12", "start:2026-10-13"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
|
||||
}
|
||||
+165
-49
@@ -44,6 +44,17 @@
|
||||
// occurrence. Either way the period key is that local date's, so the
|
||||
// period still runs exactly once.
|
||||
//
|
||||
// # Fire times from data
|
||||
//
|
||||
// A DailyAt rule fires at a time read from the household's data rather
|
||||
// than the settings: its function gives, for each household-local date,
|
||||
// the instant the date's period fires, or none. The period is that date
|
||||
// even when the instant falls on another day. The scheduler asks again on
|
||||
// every pass, so a change to the data moves the fire time by the next pass,
|
||||
// at most maxWait after the change; the claim still runs a period at most
|
||||
// once. A lookup that fails is logged and repeated every ClaimRetry, as a
|
||||
// failed claim is.
|
||||
//
|
||||
// # Settings changes
|
||||
//
|
||||
// The scheduler subscribes to the household settings and records each change
|
||||
@@ -59,6 +70,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -102,8 +114,8 @@ const ClaimRetry = time.Minute
|
||||
const maxWait = time.Hour
|
||||
|
||||
// Rule is when a schedule fires: at household-local Time ("HH:MM") every
|
||||
// day, or on Weekday only when Weekly is set. Build one with Daily, Weekly
|
||||
// or WeeklyForNextWeek.
|
||||
// day, or on Weekday only when Weekly is set, or every day at the instant
|
||||
// At gives. Build one with Daily, Weekly, WeeklyForNextWeek or DailyAt.
|
||||
type Rule struct {
|
||||
Weekly bool
|
||||
Weekday time.Weekday
|
||||
@@ -111,8 +123,15 @@ type Rule struct {
|
||||
// NextWeek keys a weekly period by the ISO week after the fire date,
|
||||
// for work done ahead for the coming week.
|
||||
NextWeek bool
|
||||
// At, instead of Time, gives the instant the period of a
|
||||
// household-local date fires.
|
||||
At FireAt
|
||||
}
|
||||
|
||||
// FireAt gives the instant the period of household-local date fires, or
|
||||
// false when that date's period does not fire.
|
||||
type FireAt func(ctx context.Context, date store.LocalDate) (time.Time, bool, error)
|
||||
|
||||
// Daily fires every day at hhmm; its period is the local date.
|
||||
func Daily(hhmm string) Rule {
|
||||
return Rule{Time: hhmm}
|
||||
@@ -129,6 +148,12 @@ func WeeklyForNextWeek(day time.Weekday, hhmm string) Rule {
|
||||
return Rule{Weekly: true, Weekday: day, Time: hhmm, NextWeek: true}
|
||||
}
|
||||
|
||||
// DailyAt fires at most once a day, at the instant at gives for the date;
|
||||
// its period is that date, wherever the instant falls.
|
||||
func DailyAt(at FireAt) Rule {
|
||||
return Rule{At: at}
|
||||
}
|
||||
|
||||
// PeriodKey returns the period key of a fire on household-local date
|
||||
// fireDate: the date ("2026-10-09") for a daily rule, the ISO week
|
||||
// ("2026-W53") for a weekly one, and the ISO week after that ("2027-W01")
|
||||
@@ -151,6 +176,12 @@ func (r Rule) validate() error {
|
||||
if !r.Weekly && (r.Weekday != time.Sunday || r.NextWeek) {
|
||||
return errors.New("a daily rule has no weekday or next week")
|
||||
}
|
||||
if r.At != nil {
|
||||
if r.Weekly || r.Time != "" {
|
||||
return errors.New("a DailyAt rule has no weekday or time")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Any date checks the time's form.
|
||||
_, err := household.LocalTime(time.UTC, store.LocalDateOf(time.Unix(0, 0).UTC()), r.Time)
|
||||
return err
|
||||
@@ -173,6 +204,10 @@ type Period struct {
|
||||
type Schedule struct {
|
||||
// Name is one of the schedule name constants.
|
||||
Name string
|
||||
// Key, when set, prefixes the schedule's period keys with Key and a
|
||||
// colon ("start:2026-10-14"), so two schedules of one Name claim
|
||||
// different periods. Name and Key together are unique.
|
||||
Key string
|
||||
// Rule returns when the schedule fires under settings. It is called
|
||||
// again after every settings change.
|
||||
Rule func(settings household.Settings) Rule
|
||||
@@ -183,6 +218,14 @@ type Schedule struct {
|
||||
Run func(ctx context.Context, p Period) error
|
||||
}
|
||||
|
||||
// label names sch in errors and logs: its Name, and its Key if set.
|
||||
func (sch Schedule) label() string {
|
||||
if sch.Key == "" {
|
||||
return sch.Name
|
||||
}
|
||||
return sch.Name + ":" + sch.Key
|
||||
}
|
||||
|
||||
// Settings is the household settings source: *household.SettingsService.
|
||||
type Settings interface {
|
||||
Get(ctx context.Context) (household.Settings, error)
|
||||
@@ -240,19 +283,22 @@ func (s *Scheduler) Register(sch Schedule) error {
|
||||
return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name)
|
||||
}
|
||||
if sch.Rule == nil {
|
||||
return fmt.Errorf("scheduler: register %s: nil rule", sch.Name)
|
||||
return fmt.Errorf("scheduler: register %s: nil rule", sch.label())
|
||||
}
|
||||
if sch.Run == nil {
|
||||
return fmt.Errorf("scheduler: register %s: nil run", sch.Name)
|
||||
return fmt.Errorf("scheduler: register %s: nil run", sch.label())
|
||||
}
|
||||
if strings.Contains(sch.Key, ":") {
|
||||
return fmt.Errorf("scheduler: register %s: key has a colon", sch.label())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
return fmt.Errorf("scheduler: register %s: already started", sch.Name)
|
||||
return fmt.Errorf("scheduler: register %s: already started", sch.label())
|
||||
}
|
||||
for _, have := range s.schedules {
|
||||
if have.Name == sch.Name {
|
||||
return fmt.Errorf("scheduler: register %s: already registered", sch.Name)
|
||||
if have.Name == sch.Name && have.Key == sch.Key {
|
||||
return fmt.Errorf("scheduler: register %s: already registered", sch.label())
|
||||
}
|
||||
}
|
||||
s.schedules = append(s.schedules, sch)
|
||||
@@ -346,7 +392,7 @@ func compile(schedules []Schedule, settings household.Settings) (*plan, error) {
|
||||
for i, sch := range schedules {
|
||||
rule := sch.Rule(settings)
|
||||
if err := rule.validate(); err != nil {
|
||||
return nil, fmt.Errorf("scheduler: %s: %w", sch.Name, err)
|
||||
return nil, fmt.Errorf("scheduler: %s: %w", sch.label(), err)
|
||||
}
|
||||
p.items[i] = item{sch: sch, rule: rule}
|
||||
}
|
||||
@@ -396,7 +442,12 @@ func (s *Scheduler) loop(ctx context.Context, p *plan) {
|
||||
case len(retry) > 0:
|
||||
timeout = s.after(ClaimRetry)
|
||||
case p != nil:
|
||||
if next, ok := p.next(now); ok {
|
||||
next, ok, err := p.next(ctx, now)
|
||||
switch {
|
||||
case err != nil:
|
||||
s.logger.ErrorContext(ctx, "scheduler: look up next fire time failed; retrying", "error", err, "retry_in", ClaimRetry.String())
|
||||
timeout = s.after(ClaimRetry)
|
||||
case ok:
|
||||
timeout = s.after(min(next.Sub(now), maxWait))
|
||||
}
|
||||
}
|
||||
@@ -423,41 +474,67 @@ func (s *Scheduler) compileChange(ctx context.Context, settings household.Settin
|
||||
return p
|
||||
}
|
||||
|
||||
// target is one schedule's period, named by the fire time it belongs to.
|
||||
// target is one schedule's period, named by the fire time it belongs to
|
||||
// and the household-local date of its period. A target whose fire time
|
||||
// could not be looked up has lookup set instead, with the stretch
|
||||
// (done, until] to look it up in again.
|
||||
type target struct {
|
||||
it item
|
||||
loc *time.Location
|
||||
at time.Time
|
||||
it item
|
||||
loc *time.Location
|
||||
at time.Time
|
||||
date store.LocalDate
|
||||
|
||||
lookup bool
|
||||
done, until time.Time
|
||||
}
|
||||
|
||||
// fire handles, for every schedule, its most recent fire time at or before
|
||||
// until if it is after done; now is the current instant. It returns the
|
||||
// targets whose claim failed, to be claimed again.
|
||||
// targets whose lookup or claim failed, to be handled again.
|
||||
func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target {
|
||||
var failed []target
|
||||
for _, it := range p.items {
|
||||
if ctx.Err() != nil {
|
||||
return failed
|
||||
}
|
||||
at, due := fireBefore(it.rule, p.loc, until)
|
||||
if !due || !at.After(done) {
|
||||
continue
|
||||
}
|
||||
if t := (target{it: it, loc: p.loc, at: at}); !s.handle(ctx, t, now) {
|
||||
if t, ok := s.fireItem(ctx, it, p.loc, done, until, now); !ok {
|
||||
failed = append(failed, t)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// retry handles targets again at now and returns those whose claim still
|
||||
// failed.
|
||||
// fireItem handles it's most recent fire time at or before until if it is
|
||||
// after done. It reports false, with the target to handle again, when the
|
||||
// fire time could not be looked up or its period's claim failed.
|
||||
func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) {
|
||||
f, due, err := fireBefore(ctx, it.rule, loc, until)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "scheduler: look up fire time failed; retrying",
|
||||
"schedule", it.sch.label(), "error", err, "retry_in", ClaimRetry.String())
|
||||
return target{it: it, loc: loc, lookup: true, done: done, until: until}, false
|
||||
}
|
||||
if !due || !f.at.After(done) {
|
||||
return target{}, true
|
||||
}
|
||||
t := target{it: it, loc: loc, at: f.at, date: f.date}
|
||||
return t, s.handle(ctx, t, now)
|
||||
}
|
||||
|
||||
// retry handles targets again at now and returns those whose lookup or
|
||||
// claim still failed.
|
||||
func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time) []target {
|
||||
var failed []target
|
||||
for i, t := range targets {
|
||||
if ctx.Err() != nil {
|
||||
return append(failed, targets[i:]...)
|
||||
}
|
||||
if t.lookup {
|
||||
if again, ok := s.fireItem(ctx, t.it, t.loc, t.done, t.until, now); !ok {
|
||||
failed = append(failed, again)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !s.handle(ctx, t, now) {
|
||||
failed = append(failed, t)
|
||||
}
|
||||
@@ -469,9 +546,11 @@ func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time)
|
||||
// its fire time is more than CatchUpWindow before now. It reports false
|
||||
// when the claim failed and the period is still unclaimed.
|
||||
func (s *Scheduler) handle(ctx context.Context, t target, now time.Time) bool {
|
||||
it, at := t.it, t.at
|
||||
date := household.Today(at, t.loc)
|
||||
it, at, date := t.it, t.at, t.date
|
||||
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
|
||||
if it.sch.Key != "" {
|
||||
period.Key = it.sch.Key + ":" + period.Key
|
||||
}
|
||||
if it.rule.NextWeek {
|
||||
period.Week = household.WeekStart(addDays(date, 7))
|
||||
} else {
|
||||
@@ -515,16 +594,19 @@ func (s *Scheduler) claim(ctx context.Context, name, key string, now time.Time)
|
||||
}
|
||||
|
||||
// next returns the earliest fire time of any schedule after now, or false
|
||||
// when there is no schedule.
|
||||
func (p *plan) next(now time.Time) (time.Time, bool) {
|
||||
// when there is none.
|
||||
func (p *plan) next(ctx context.Context, now time.Time) (time.Time, bool, error) {
|
||||
var next time.Time
|
||||
for _, it := range p.items {
|
||||
at, ok := fireAfter(it.rule, p.loc, now)
|
||||
if ok && (next.IsZero() || at.Before(next)) {
|
||||
next = at
|
||||
f, ok, err := fireAfter(ctx, it.rule, p.loc, now)
|
||||
if err != nil {
|
||||
return time.Time{}, false, fmt.Errorf("%s: %w", it.sch.label(), err)
|
||||
}
|
||||
if ok && (next.IsZero() || f.at.Before(next)) {
|
||||
next = f.at
|
||||
}
|
||||
}
|
||||
return next, !next.IsZero()
|
||||
return next, !next.IsZero(), nil
|
||||
}
|
||||
|
||||
// window is how many days either side of a date fireBefore and fireAfter
|
||||
@@ -532,46 +614,80 @@ func (p *plan) next(now time.Time) (time.Time, bool) {
|
||||
// each side.
|
||||
const window = 8
|
||||
|
||||
// atWindow is how many days either side of a date fireBefore and fireAfter
|
||||
// look up a DailyAt rule's fire times. A period firing further off is
|
||||
// looked up on a later pass, which comes within maxWait.
|
||||
const atWindow = 2
|
||||
|
||||
// fireTime is a fire time and the household-local date of its period.
|
||||
type fireTime struct {
|
||||
at time.Time
|
||||
date store.LocalDate
|
||||
}
|
||||
|
||||
// fireBefore returns the latest fire time of r in zone loc at or before t.
|
||||
func fireBefore(r Rule, loc *time.Location, t time.Time) (time.Time, bool) {
|
||||
var best time.Time
|
||||
for _, at := range fireTimes(r, loc, t) {
|
||||
if !at.After(t) && (best.IsZero() || at.After(best)) {
|
||||
best = at
|
||||
func fireBefore(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) {
|
||||
fs, err := fireTimes(ctx, r, loc, t)
|
||||
if err != nil {
|
||||
return fireTime{}, false, err
|
||||
}
|
||||
var best fireTime
|
||||
for _, f := range fs {
|
||||
if !f.at.After(t) && (best.at.IsZero() || f.at.After(best.at)) {
|
||||
best = f
|
||||
}
|
||||
}
|
||||
return best, !best.IsZero()
|
||||
return best, !best.at.IsZero(), nil
|
||||
}
|
||||
|
||||
// fireAfter returns the earliest fire time of r in zone loc after t.
|
||||
func fireAfter(r Rule, loc *time.Location, t time.Time) (time.Time, bool) {
|
||||
var best time.Time
|
||||
for _, at := range fireTimes(r, loc, t) {
|
||||
if at.After(t) && (best.IsZero() || at.Before(best)) {
|
||||
best = at
|
||||
func fireAfter(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) {
|
||||
fs, err := fireTimes(ctx, r, loc, t)
|
||||
if err != nil {
|
||||
return fireTime{}, false, err
|
||||
}
|
||||
var best fireTime
|
||||
for _, f := range fs {
|
||||
if f.at.After(t) && (best.at.IsZero() || f.at.Before(best.at)) {
|
||||
best = f
|
||||
}
|
||||
}
|
||||
return best, !best.IsZero()
|
||||
return best, !best.at.IsZero(), nil
|
||||
}
|
||||
|
||||
// fireTimes returns r's fire times on the local dates within window days
|
||||
// of t's. A rule that fails to resolve was validated at compile, so it
|
||||
// cannot.
|
||||
func fireTimes(r Rule, loc *time.Location, t time.Time) []time.Time {
|
||||
// fireTimes returns r's fire times for the local dates within window days
|
||||
// of t's, or atWindow days for a DailyAt rule. A Time that fails to
|
||||
// resolve was validated at compile, so it cannot; a DailyAt lookup that
|
||||
// fails is an error.
|
||||
func fireTimes(ctx context.Context, r Rule, loc *time.Location, t time.Time) ([]fireTime, error) {
|
||||
today := household.Today(t, loc)
|
||||
var out []time.Time
|
||||
for i := -window; i <= window; i++ {
|
||||
span := window
|
||||
if r.At != nil {
|
||||
span = atWindow
|
||||
}
|
||||
var out []fireTime
|
||||
for i := -span; i <= span; i++ {
|
||||
d := addDays(today, i)
|
||||
if r.Weekly && weekday(d) != r.Weekday {
|
||||
continue
|
||||
}
|
||||
if r.At != nil {
|
||||
at, ok, err := r.At(ctx, d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fire time of %s: %w", d, err)
|
||||
}
|
||||
if ok {
|
||||
out = append(out, fireTime{at: at, date: d})
|
||||
}
|
||||
continue
|
||||
}
|
||||
at, err := household.LocalTime(loc, d, r.Time)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("scheduler: validated rule %+v: %v", r, err))
|
||||
}
|
||||
out = append(out, at)
|
||||
out = append(out, fireTime{at: at, date: d})
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addDays returns d plus n calendar days.
|
||||
|
||||
Reference in New Issue
Block a user