diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index c39d436..f8a95bf 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -209,6 +209,11 @@ type Schedule struct { // colon ("start:2026-10-14"), so two schedules of one Name claim // different periods. Name and Key together are unique. Key string + // PeriodKey, when set, gives the period key claimed for p in place of + // the rule's, such as "list_ready:chat:2026-10-19" for the week p.Week; + // p.Key is the rule's key. It must give each period its own key. A + // schedule sets Key or PeriodKey, not both. + PeriodKey func(p Period) string // Rule returns when the schedule fires under settings. It is called // again after every settings change. Rule func(settings household.Settings) Rule @@ -293,6 +298,9 @@ func (s *Scheduler) Register(sch Schedule) error { if strings.Contains(sch.Key, ":") { return fmt.Errorf("scheduler: register %s: key has a colon", sch.label()) } + if sch.Key != "" && sch.PeriodKey != nil { + return fmt.Errorf("scheduler: register %s: both key and period key", sch.label()) + } s.mu.Lock() defer s.mu.Unlock() if s.started { @@ -562,14 +570,17 @@ func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time) func (s *Scheduler) handle(ctx context.Context, t target, now time.Time) bool { 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 { period.Week = household.WeekStart(date) } + switch { + case it.sch.Key != "": + period.Key = it.sch.Key + ":" + period.Key + case it.sch.PeriodKey != nil: + period.Key = it.sch.PeriodKey(period) + } log := s.logger.With("schedule", it.sch.Name, "period", period.Key, "fire_at", at.Format(time.RFC3339)) claimed, err := s.claim(ctx, it.sch.Name, period.Key, now) diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 3e288ee..534eaca 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -722,6 +722,7 @@ func TestRegister(t *testing.T) { {Name: "hourly", Rule: reminder(&rec).Rule, Run: rec.run}, {Name: scheduler.DayOfReminder, Run: rec.run}, {Name: scheduler.DayOfReminder, Rule: reminder(&rec).Rule}, + {Name: scheduler.DayOfReminder, Key: "start", PeriodKey: func(p scheduler.Period) string { return p.Key }, Rule: reminder(&rec).Rule, Run: rec.run}, } for _, sch := range bad { if err := s.Register(sch); err == nil { diff --git a/internal/scheduler/weekly_automation_test.go b/internal/scheduler/weekly_automation_test.go index b21073c..993d376 100644 --- a/internal/scheduler/weekly_automation_test.go +++ b/internal/scheduler/weekly_automation_test.go @@ -113,7 +113,8 @@ func (s *shopEnv) wantLogged(want string) { // TestChatAutosendSendsComingWeek: at the seeded Saturday 08:00 the // schedule posts the coming week's Supply run, W43's, to Google Chat // through SendToChat, in that week's thread, and records sent_to_chat_at; -// its period is shopping_chat_autosend:2026-W43. It posts once. +// its period is claimed in schedule_runs as list_ready:chat:2026-10-19, +// the coming week's start. It posts once. func TestChatAutosendSendsComingWeek(t *testing.T) { e := newEnv(t) s := e.shopping() @@ -143,13 +144,13 @@ func TestChatAutosendSendsComingWeek(t *testing.T) { t.Error("sent_to_chat_at not recorded") } s.wantLogged("Supply run sent to Google Chat") - s.wantLogged("key=list_ready:chat:2026-10-19") + s.wantLogged("period=list_ready:chat:2026-10-19") r.runUntil(at(2026, 10, 24, 7, 0)) if n := len(s.chat.Posts()); n != 1 { t.Errorf("a week on: %d posts, want 1", n) } - wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"2026-W42", "2026-W43"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"}) } // TestChatAutosendFollowsSettings: chat_autosend_dow and @@ -249,7 +250,7 @@ func TestChatAutosendSkips(t *testing.T) { t.Errorf("sent_to_chat_at = %v, want %v", l.SentToChatAt, sentBefore) } s.wantLogged("Supply run not sent to Google Chat: " + c.reason) - wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"2026-W42", "2026-W43"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"}) }) } } @@ -295,7 +296,7 @@ func TestChatAutosendFailureSurfaces(t *testing.T) { if got := logs.String(); !strings.Contains(got, "level=ERROR") || !strings.Contains(got, "scheduler: run failed") || !strings.Contains(got, "schedule=shopping_chat_autosend") { t.Errorf("scheduler logs = %s, want the refused send logged as a failed run", got) } - wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"2026-W42", "2026-W43"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"}) } func equalPtr(a, b *string) bool { diff --git a/internal/shopping/schedule.go b/internal/shopping/schedule.go index e531621..73182fa 100644 --- a/internal/shopping/schedule.go +++ b/internal/shopping/schedule.go @@ -26,8 +26,9 @@ const ( ) // AutosendKey identifies the automatic send of the Supply run of the week -// starting weekStart, list_ready:chat:YYYY-MM-DD, in the schedule's logs. -// The send itself is recorded in the list's sent_to_chat_at. +// starting weekStart, list_ready:chat:YYYY-MM-DD: it is the +// shopping_chat_autosend period key claimed in schedule_runs. The send +// itself is recorded in the list's sent_to_chat_at. func AutosendKey(weekStart store.LocalDate) string { return string(notify.ListReady) + ":" + string(notify.ChannelChat) + ":" + weekStart.String() } @@ -35,8 +36,8 @@ func AutosendKey(weekStart store.LocalDate) string { // ChatAutosendSchedule is the shopping_chat_autosend schedule: at the // household's chat_autosend_dow and chat_autosend_time it sends the Supply // run of the coming week, the week after the fire date's, to Google Chat -// with Autosend. Its period scheduler.WeeklyForNextWeek keys -// shopping_chat_autosend:YYYY-Www for that week. A send that fails is the +// with Autosend. Its period is that week, scheduler.WeeklyForNextWeek's, +// claimed under AutosendKey of the week's Monday. A send that fails is the // period's error and is not retried; the household can still send it from // the Supply run. func (s *Service) ChatAutosendSchedule() scheduler.Schedule { @@ -45,12 +46,15 @@ func (s *Service) ChatAutosendSchedule() scheduler.Schedule { Rule: func(settings household.Settings) scheduler.Rule { return scheduler.WeeklyForNextWeek(time.Weekday(settings.ChatAutosendDow), settings.ChatAutosendTime) }, + PeriodKey: func(p scheduler.Period) string { + return AutosendKey(p.Week) + }, Run: func(ctx context.Context, p scheduler.Period) error { skipped, err := s.Autosend(ctx, p.Week) if err != nil { return err } - log := s.logger.With("period", p.Key, "key", AutosendKey(p.Week), "week_start", p.Week.String()) + log := s.logger.With("period", p.Key, "week_start", p.Week.String()) if skipped != "" { log.InfoContext(ctx, "Supply run not sent to Google Chat: "+skipped) return nil