Claim chat autosend periods as list_ready:chat:YYYY-MM-DD (F20-01)
CI / ci (push) Successful in 7m36s
CI / ci (pull_request) Successful in 7m21s

The shopping_chat_autosend schedule now claims its schedule_runs period
under AutosendKey of the coming week's Monday instead of the ISO week key.
Schedule gains an optional PeriodKey hook that replaces the rule's key;
Register rejects a schedule setting both Key and PeriodKey. The fake-clock
tests assert the stored period keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 16:11:51 -04:00
co-authored by Claude Opus 5.5
parent 7f48a67ad4
commit 9ff50fd558
4 changed files with 30 additions and 13 deletions
+14 -3
View File
@@ -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)
+1
View File
@@ -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 {
+6 -5
View File
@@ -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 {
+9 -5
View File
@@ -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