Weekly suggestions schedule (Feature 20) #172

9 changed files with 771 additions and 127 deletions
+3
View File
@@ -192,6 +192,9 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil {
return err
}
if err := sched.Register(suggestSvc.WeeklySchedule()); 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 {
+39 -18
View File
@@ -309,20 +309,24 @@ automatic** (D-021).
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
grounds the survivors in the model's order with `Grounder.GroundMany`,
as many as are still wanted at a time, until 5 are kept or none are
left. A Side quest no recipe grounds is dropped; each round's grounded
ones are stored before the next — a dinner with status `suggested`,
origin `side_quest`, grounding_status `grounded` with the recipe's
recipe_id, photo_id, method, minutes_total and difficulty and the
model's cuisine, and a `pending` suggestion with the name it is
suggested under, the reason and `cited_regular_ids` — unless, since the
job read its input, a dinner it repeats was added, a suggestion it
repeats passed, or a Regular it cites renamed or archived (checked in
the storing transaction). The job publishes
`{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
left. A Side quest no recipe grounds is dropped. A `replenish` batch
stores each round's grounded ones before the next; a `scheduled` or
`manual` batch, which notifies, holds them and stores them all in one
transaction once its last round is done. Each is stored as a dinner
with status `suggested`, origin `side_quest`, grounding_status
`grounded` with the recipe's recipe_id, photo_id, method,
minutes_total and difficulty and the model's cuisine, and a `pending`
suggestion with the name it is suggested under, the reason and
`cited_regular_ids` — unless, since the job read its input, a dinner
it repeats was added, a suggestion it repeats passed, or a Regular it
cites renamed or archived (checked in the storing transaction). The job
publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
skill or `normalize_recipe` failed; no Side quest is made up instead,
D-017), `search_not_configured` or `search_failed`, and the batch is set
`failed`; Side quests stored before the failure stay in the deck. If
`failed`. A failed `scheduled` or `manual` batch has stored nothing; a
failed `replenish` batch's Side quests stored before the failure stay
in the deck. If
recording the batch's end fails, its job fails; the job runner retries
a job's final status write with backoff until it lands, each attempt
bounded at 5 seconds; at shutdown an attempt that fails or times out is
@@ -775,7 +779,7 @@ purge are fixed and not configurable.
| Schedule | When | Does |
| --- | --- | --- |
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. |
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
| `plan_draft` | Friday 09:00 | AI drafts next week's plan. |
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
@@ -803,6 +807,16 @@ claimed without running (logged as missed) so it never runs later. DST
follows `household.LocalTime`: a skipped time fires later by the gap, a
repeated time fires once, at its first occurrence.
Each schedule is built by the service that does its work, as a
`scheduler.Schedule` value, and registered in `cmd/server` before the
scheduler starts: `suggest.Service.WeeklySchedule()` is `suggestions_weekly`.
Its run calls `StartScheduledBatch(period.Week)`, which starts the same
batch and job as `POST /api/v1/suggestions/batches` (`StartBatch(manual)`),
with the `scheduled` trigger, and returns at once; the batch runs as a job. When a
batch is already pending or running the period starts none and logs it.
Fake-clock tests of a schedule live in `internal/scheduler`, where
`SetClock` is visible to tests.
## Notifications
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
@@ -820,12 +834,19 @@ repeated time fires once, at its first occurrence.
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
run is first created, by `POST /shopping/{weekStart}/generate` or
after Push to Calendar; remaking it does not notify.
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
`manual` suggestion batch first adds Side quests to the deck, in the
transaction storing them, so they are announced even when a later
round or recording the batch's end fails; it names that round's Side
quests, and a later round's emit stores nothing. A `replenish` batch
never notifies.
- `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it
was scheduled for (carried in its job, so a batch caught up late is
still keyed by its own week), or `new_suggestions:{batchId}` for a
`manual` one (URL `/discover`), once, when the batch's last round is
done, in the one transaction that stores all its Side quests, so they
and their notification commit together or not at all. Its title
counts every Side quest the batch stored ("5 new Side quests are in")
and its body names them. A batch that fails at any round, as when
`suggest_side_quests` or a later round's `normalize_recipe` fails (the
failure is in `ai_runs`), stores no Side quest and sends nothing, so
no stored Side quest goes unannounced. A `replenish` batch never
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
+7 -5
View File
@@ -77,13 +77,15 @@ func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
}
}
// NewSuggestions is the new_suggestions event of suggestion batch batchID,
// made in the week starting on week, naming its Side quests.
func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event {
// NewSuggestions is the new_suggestions event keyed by id, the ISO week
// ("2026-W41") of a scheduled suggestion batch or the batch id of a manual
// one, of the week starting on week: how many new Side quests there are,
// and their names.
func NewSuggestions(id string, week store.LocalDate, names []string) notify.Event {
return notify.Event{
Type: notify.NewSuggestions,
Key: string(notify.NewSuggestions) + ":" + batchID,
Title: "New Side quests are in",
Key: string(notify.NewSuggestions) + ":" + id,
Title: count(len(names), "new Side quest is in", "new Side quests are in"),
Body: nameList(names),
URL: "/discover",
Week: week,
+12 -2
View File
@@ -110,9 +110,19 @@ func TestMessages(t *testing.T) {
key: "new_suggestions:01J9BATCH",
url: "/discover",
week: "2026-10-12",
title: "New Side quests are in",
title: "5 new Side quests are in",
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more",
chatT: "New Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
chatT: "5 new Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
},
{
name: "new suggestions of a scheduled week",
ev: NewSuggestions("2026-W42", week, []string{"Pho"}),
key: "new_suggestions:2026-W42",
url: "/discover",
week: "2026-10-12",
title: "1 new Side quest is in",
body: "Pho",
chatT: "1 new Side quest is in: Pho. Open Discover →",
},
{
name: "rotation review",
+370
View File
@@ -0,0 +1,370 @@
package scheduler_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"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/jobs"
"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/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
// The tests in this file run suggest's suggestions_weekly schedule on the
// fake clock against the real suggestion service, job runner, harness and
// notification outbox, with the AI and recipe sources faked.
// suggestEnv is a suggestion service over a scheduler env's store, the
// "process" a scheduler runs it in. side answers suggest_side_quests and
// model normalize_recipe while grounding.
type suggestEnv struct {
e *env
svc *suggest.Service
side *llm.Fake
model *llm.Fake
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
logs *syncBuffer
stop func()
}
// syncBuffer collects log output written by concurrent goroutines.
type syncBuffer struct {
mu sync.Mutex
buf strings.Builder
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// regulars stores the Regulars Beef Chili and Chicken Fajitas, so a batch
// has Regulars to suggest from.
func (e *env) regulars() {
e.t.Helper()
for _, name := range []string{"Beef Chili", "Chicken Fajitas"} {
now := store.Now().String()
if _, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "regular",
Origin: "regular",
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
e.t.Fatal(err)
}
}
}
// suggestions builds a suggestion service over e with a started job
// runner, which stop stops (and the test's end, if not before).
func (e *env) suggestions() *suggestEnv {
e.t.Helper()
s := &suggestEnv{
e: e,
side: &llm.Fake{},
model: &llm.Fake{},
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
images: &recipestest.Images{JPEGs: map[string][]byte{}},
logs: &syncBuffer{},
}
g := recipestest.NewGrounder(e.t, e.st, e.t.TempDir(), s.search, s.pages, s.images, s.model)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
e.t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return s.side, nil },
Store: e.st,
Guard: guard,
}
suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SuggestSideQuests(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(s.logs, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
e.t.Fatal(err)
}
s.svc, err = suggest.NewService(e.st, g, suggester, runner, notifytest.New(e.t, e.st), logger)
if err != nil {
e.t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := runner.Start(ctx); err != nil {
cancel()
e.t.Fatal(err)
}
var once sync.Once
s.stop = func() {
once.Do(func() {
cancel()
runner.Wait()
})
}
e.t.Cleanup(s.stop)
return s
}
// week scripts the next batch: suggest_side_quests answers with the
// Side quests named names, each grounded by its own recipe.
func (s *suggestEnv) week(names ...string) {
s.e.t.Helper()
var quests []string
for _, name := range names {
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
s.search.Results[name] = []recipes.Candidate{{URL: page}}
s.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
s.images.JPEGs[photo] = recipestest.JPEG(s.e.t, 400, 320, uint8(len(s.images.JPEGs)+1))
s.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 35))
quests = append(quests, fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":"Like your Beef Chili.","cited_regulars":["Beef Chili"],"search_query":%q}`, name, name))
}
js := `{"suggestions":[` + strings.Join(quests, ",") + `]}`
s.side.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
// batch is a suggestion_batches row.
type batch struct {
ID, Trigger, Status string
}
// batches returns every suggestion batch, oldest first.
func (e *env) batches() []batch {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT id, trigger, status FROM suggestion_batches ORDER BY created_at, id`)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var out []batch
for rows.Next() {
var b batch
if err := rows.Scan(&b.ID, &b.Trigger, &b.Status); err != nil {
e.t.Fatal(err)
}
out = append(out, b)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return out
}
// settled waits until no batch is pending or running and returns the
// batches.
func (e *env) settled() []batch {
e.t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
bs := e.batches()
busy := false
for _, b := range bs {
busy = busy || b.Status == suggest.BatchPending || b.Status == suggest.BatchRunning
}
if !busy {
return bs
}
if time.Now().After(deadline) {
e.t.Fatalf("batches still under way: %+v", bs)
}
time.Sleep(5 * time.Millisecond)
}
}
// wantBatches fails unless the batches are scheduled with statuses want.
func wantBatches(t *testing.T, what string, got []batch, want ...string) {
t.Helper()
ok := len(got) == len(want)
for i := 0; ok && i < len(got); i++ {
ok = got[i].Trigger == suggest.TriggerScheduled && got[i].Status == want[i]
}
if !ok {
t.Fatalf("%s: batches = %+v, want scheduled %q", what, got, want)
}
}
// notified returns the new_suggestions keys stored, oldest first, and
// reports an error for any whose title is not the one titles gives it.
func (e *env) notified(titles ...string) []string {
e.t.Helper()
var keys []string
rows := notifytest.Rows(e.t, e.st, notify.NewSuggestions)
for i, r := range rows {
keys = append(keys, r.Key)
if i < len(titles) && r.Payload.Title != titles[i] {
e.t.Errorf("new_suggestions %s title = %q, want %q", r.Key, r.Payload.Title, titles[i])
}
}
return keys
}
// TestSuggestionsWeeklyOneBatchAndNotificationPerWeek: at the seeded
// Sunday 08:00 the schedule starts one scheduled batch a week, period
// suggestions_weekly:YYYY-Www, and each tells the household once, with
// new_suggestions:YYYY-Www counting its new Side quests.
func TestSuggestionsWeeklyOneBatchAndNotificationPerWeek(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde", "Chicken Shawarma Bowls")
s.week("Lamb Kofta", "Black Bean Tostadas", "Pork Carnitas")
// Wednesday Oct 7: the Sunday Oct 4 08:00 fire is days ago, missed.
r := e.start(at(2026, 10, 7, 12, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 7, 59))
wantBatches(t, "before Sunday 08:00", e.settled())
wantKeys(t, "new_suggestions before Sunday 08:00", e.notified(), nil)
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "at Sunday 08:00", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions of W41", e.notified("2 new Side quests are in"), []string{"new_suggestions:2026-W41"})
r.runUntil(at(2026, 10, 18, 7, 59))
wantBatches(t, "rest of W41", e.settled(), suggest.BatchDone)
r.runUntil(at(2026, 10, 18, 9, 0))
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
wantKeys(t, "new_suggestions of W41 and W42", e.notified("2 new Side quests are in", "3 new Side quests are in"),
[]string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"})
}
// TestSuggestionsWeeklyFollowsSettings: the schedule fires at the
// household's suggestions_dow and suggestions_time.
func TestSuggestionsWeeklyFollowsSettings(t *testing.T) {
e := newEnv(t)
e.regulars()
e.update(household.SettingsPatch{SuggestionsDow: ptr(3), SuggestionsTime: ptr("18:30")})
s := e.suggestions()
s.week("Chili Verde")
r := e.start(at(2026, 10, 12, 12, 0), s.svc.WeeklySchedule()) // Monday
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
wantBatches(t, "before Wednesday 18:30", e.settled())
r.runUntil(at(2026, 10, 14, 18, 30))
wantBatches(t, "at Wednesday 18:30", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions", e.notified("1 new Side quest is in"), []string{"new_suggestions:2026-W42"})
}
// TestSuggestionsWeeklyAIFailureSendsNothing: when suggest_side_quests
// fails the batch fails with the failure logged and recorded in ai_runs,
// and the household is not notified.
func TestSuggestionsWeeklyAIFailureSendsNothing(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "after the AI failed", e.settled(), suggest.BatchFailed)
wantKeys(t, "new_suggestions after the AI failed", e.notified(), nil)
// W40 is the boot's missed period, recorded without running.
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
var status, msg string
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'suggest_side_quests'`).Scan(&status, &msg); err != nil {
t.Fatalf("ai_runs row of suggest_side_quests: %v", err)
}
if status != string(harness.StatusProviderError) || msg == "" {
t.Errorf("ai_runs suggest_side_quests = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
}
if logs := s.logs.String(); !strings.Contains(logs, "suggestion batch failed") || !strings.Contains(logs, "reason="+suggest.ReasonAIFailed) {
t.Errorf("logs = %s, want the batch failure with reason %s", logs, suggest.ReasonAIFailed)
}
}
// TestSuggestionsWeeklyNoDuplicateAfterRestart: a restart after the week's
// batch ran, still within the catch-up window, starts no second batch and
// sends no second notification: the period is already claimed.
func TestSuggestionsWeeklyNoDuplicateAfterRestart(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde", "Chicken Shawarma Bowls")
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "first process", e.settled(), suggest.BatchDone)
r.stop()
s.stop()
// The restarted process has a new job runner, suggestion service and
// scheduler; it would answer a second batch if one were started.
s2 := e.suggestions()
s2.week("Lamb Kofta")
r2 := e.start(at(2026, 10, 11, 9, 0), s2.svc.WeeklySchedule())
r2.runUntil(at(2026, 10, 18, 7, 59))
wantBatches(t, "after restart", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions after restart", e.notified(), []string{"new_suggestions:2026-W41"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
}
// TestSuggestionsWeeklyCatchUpAfterTheWeekEnds: a Sunday fire caught up on
// Monday, in the next ISO week, still runs W41's batch keyed by W41, and
// the next Sunday's batch notifies for W42.
func TestSuggestionsWeeklyCatchUpAfterTheWeekEnds(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde")
s.week("Lamb Kofta")
r := e.start(at(2026, 10, 12, 7, 0), s.svc.WeeklySchedule()) // Monday, 23h late
wantBatches(t, "after catch-up", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions after catch-up", e.notified(), []string{"new_suggestions:2026-W41"})
r.runUntil(at(2026, 10, 18, 8, 0))
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
wantKeys(t, "new_suggestions", e.notified(), []string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
}
// TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay: a period that
// fires while a batch is pending starts no second one and says so.
func TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.stop() // the manual batch stays pending
manual, err := s.svc.StartBatch(context.Background(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
if bs := e.batches(); len(bs) != 1 || bs[0].ID != manual.ID {
t.Fatalf("batches = %+v, want only the manual batch %s", bs, manual.ID)
}
if logs := s.logs.String(); !strings.Contains(logs, "scheduled suggestion batch not started") || !strings.Contains(logs, "period=2026-W41") {
t.Errorf("logs = %s, want the skipped period logged", logs)
}
}
+97 -37
View File
@@ -22,6 +22,9 @@ import (
// BatchJob is the KindSuggestionBatch payload: the batch to run.
type BatchJob struct {
BatchID string `json:"batch_id"`
// Week is the YYYY-MM-DD Monday of the ISO week a scheduled batch is
// for, which keys its new_suggestions; empty for any other trigger.
Week string `json:"week,omitempty"`
}
// Batch stages, as the job's result reports them.
@@ -50,19 +53,22 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// wanted at a time, until BatchSize are kept or none are left. A Side quest
// no recipe grounds is dropped; any other grounding failure fails the
// batch, as does suggest_side_quests failing, with no Side quest made up
// instead. Each round's grounded Side quests are stored before the next
// round starts, so the deck fills as the batch goes. The batch ends done
// or failed; Side quests stored before a failure stay in the deck. When
// recording the batch's end fails, so does the job; when it times out at
// shutdown, the job is left running and the batch with it, for the next
// start to fail.
// instead. The batch ends done or failed. When recording the batch's end
// fails, so does the job; when it times out at shutdown, the job is left
// running and the batch with it, for the next start to fail.
//
// A scheduled or manual batch emits new_suggestions, keyed by the batch,
// in the transaction that first adds Side quests to the deck, so the
// household is told of every Side quest stored, whether or not the batch
// then ends done: a later round, or recording the batch's end, may fail.
// A replenish batch, which tops up the deck as the household swipes, never
// notifies. See store.
// When a batch stores its Side quests depends on whether it notifies (see
// noticeOf). A scheduled or manual batch notifies: it holds every round's
// grounded Side quests and, once the last round is done, stores them all
// in one transaction with one new_suggestions counting and naming every
// Side quest stored. So the household is told of exactly what the batch
// added, once; and a batch that fails at any round, including a later
// round's normalize_recipe, stores nothing and sends nothing, leaving no
// Side quest in the deck that was never announced. A replenish batch,
// which tops up the deck as the household swipes and never notifies,
// stores each round's Side quests before the next round starts, so the
// deck fills as it goes; those stored before a failure stay in the deck.
// See store.
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID)
if err != nil {
@@ -78,7 +84,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
if n == 0 {
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
}
res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
res, err := s.fill(ctx, batch.Trigger, p, publish)
if err == nil {
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
return batchResult{}, err
@@ -98,10 +104,12 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
return batchResult{}, errors.Join(err, finishErr)
}
// fill does runBatch's work, returning a *jobs.Failure for a failure the
// household may see. When notifies is true, store emits new_suggestions
// with the Side quests it adds.
func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) {
// fill does runBatch's work for job of a batch with trigger, returning a
// *jobs.Failure for a failure the household may see. A batch with a notice
// is stored once, when its last round is done; any other batch round by
// round.
func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publish func(batchResult) error) (batchResult, error) {
batchID := job.BatchID
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
if err := publish(res); err != nil {
return batchResult{}, err
@@ -110,6 +118,10 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
if err != nil {
return batchResult{}, err
}
note, err := noticeOf(trigger, job, in.Context.Today)
if err != nil {
return batchResult{}, err
}
out, err := s.suggest(ctx, batchID, in)
if err != nil {
if ctx.Err() != nil {
@@ -122,34 +134,81 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
if err := publish(res); err != nil {
return batchResult{}, err
}
var week *store.LocalDate
if notifies {
w := household.WeekStart(in.Context.Today)
week = &w
}
left := out.Suggestions
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
// held are a notifying batch's grounded Side quests, not yet stored.
var held []groundedQuest
for len(res.SuggestionIDs)+len(held) < BatchSize && len(left) > 0 {
round := left[:min(BatchSize-len(res.SuggestionIDs)-len(held), len(left))]
left = left[len(round):]
grounded, err := s.ground(ctx, round)
if err != nil {
return batchResult{}, err
}
res.Dropped += len(round) - len(grounded)
ids, err := s.store(ctx, batchID, week, grounded)
if err != nil {
if note != nil {
held = append(held, grounded...)
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
return batchResult{}, err
}
res.Dropped += len(grounded) - len(ids)
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
if err := publish(res); err != nil {
return batchResult{}, err
}
}
if note != nil {
if err := s.storeInto(ctx, &res, note, held); err != nil {
return batchResult{}, err
}
}
res.Stage = StageDone
return res, nil
}
// storeInto stores quests for res's batch with note (see store) and adds
// the suggestions stored, and the Side quests left out, to res.
func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error {
ids, err := s.store(ctx, res.BatchID, note, quests)
if err != nil {
return err
}
res.Dropped += len(quests) - len(ids)
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
return nil
}
// notice is how a batch's new_suggestions is keyed: by id, as
// new_suggestions:{id}, for the week starting on week.
type notice struct {
id string
week store.LocalDate
}
// noticeOf returns the notice of job, run for a batch with trigger on
// household-local date today, or nil for a replenish batch, which never
// notifies. A scheduled batch is keyed by the ISO week it was scheduled
// for ("2026-W41"), not the week it happens to run in, so each week's
// schedule tells the household once even when its batch runs late; a
// manual batch is keyed by its batch id, for the week of today. A job
// whose week does not fit its trigger is an error.
func noticeOf(trigger string, job BatchJob, today store.LocalDate) (*notice, error) {
if trigger != TriggerScheduled {
if job.Week != "" {
return nil, fmt.Errorf("suggest: %s batch %s has a week", trigger, job.BatchID)
}
if trigger == TriggerReplenish {
return nil, nil
}
return &notice{id: job.BatchID, week: household.WeekStart(today)}, nil
}
week, err := store.ParseLocalDate(job.Week)
if err != nil {
return nil, fmt.Errorf("suggest: scheduled batch %s week: %w", job.BatchID, err)
}
if household.WeekStart(week) != week {
return nil, fmt.Errorf("suggest: scheduled batch %s week %s is not a Monday", job.BatchID, week)
}
return &notice{id: household.PeriodKey(week), week: week}, nil
}
// request reads what suggest_side_quests is given: the household context,
// the deck's names and every passed name (ListPassedNames). A household with no Regulars
// fails with ReasonNoRegulars.
@@ -224,13 +283,14 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// or archived. Passed names are read in the same transaction as the store,
// so a Pass or drop committed before it is never missed.
//
// When week is not nil and a Side quest is stored, new_suggestions:{batchID}
// naming those stored is emitted in the same transaction, for the week
// starting week, so the Side quests and their notification commit together
// When note is not nil and a Side quest is stored, new_suggestions keyed by
// note, counting and naming those stored, is emitted in the same
// transaction, so the Side quests and their notification commit together
// or not at all: a notification that cannot be stored fails the store, and
// the batch with it. The event's key makes the first round to store Side
// quests the one that notifies; a later round's emit stores nothing.
func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) {
// the batch with it. A notifying batch calls store once, with all its
// Side quests, so the count is the batch's (see runBatch). This is the
// only place new_suggestions is emitted.
func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) {
ids := []string{}
var stored []string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -298,15 +358,15 @@ func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDa
ids = append(ids, id)
stored = append(stored, d.Name)
}
if week == nil || len(stored) == 0 {
if note == nil || len(stored) == 0 {
return nil
}
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored))
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(note.id, note.week, stored))
})
if err != nil {
return nil, err
}
if week != nil && len(stored) > 0 {
if note != nil && len(stored) > 0 {
s.notifier.Wake()
}
return ids, nil
+36
View File
@@ -0,0 +1,36 @@
package suggest
import (
"context"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
)
// WeeklySchedule is the suggestions_weekly schedule: at the household's
// suggestions_dow and suggestions_time it starts a scheduled batch for the
// ISO week of the fire date, its period scheduler.Weekly keys
// suggestions_weekly:YYYY-Www. The batch tells the household of the Side
// quests it adds with new_suggestions:YYYY-Www for that week, once; a batch
// that fails before adding any, as when the AI fails, sends nothing. When a
// batch is already pending or running the period starts none, and says so.
func (s *Service) WeeklySchedule() scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.SuggestionsWeekly,
Rule: func(settings household.Settings) scheduler.Rule {
return scheduler.Weekly(time.Weekday(settings.SuggestionsDow), settings.SuggestionsTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
b, err := s.StartScheduledBatch(ctx, p.Week)
if err != nil {
return err
}
if !b.Created {
s.logger.WarnContext(ctx, "scheduled suggestion batch not started: a batch is already under way",
"period", p.Key, "batch_id", b.ID, "trigger", b.Trigger)
}
return nil
},
}
}
+29 -9
View File
@@ -19,6 +19,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
@@ -154,17 +155,35 @@ type Batch struct {
Created bool `json:"-"`
}
// StartBatch creates a batch with trigger and enqueues the
// StartBatch creates a manual or replenish batch and enqueues the
// KindSuggestionBatch job that runs it, unless a batch is already pending
// or running, which it returns instead with Created false.
// or running, which it returns instead with Created false. A scheduled
// batch is started by StartScheduledBatch.
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
if trigger == TriggerScheduled {
return Batch{}, errors.New("suggest: a scheduled batch is started by StartScheduledBatch")
}
if !slices.Contains(triggers, trigger) {
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
}
return s.start(ctx, trigger, "")
}
// StartScheduledBatch is StartBatch for a scheduled batch for the ISO week
// starting on Monday week, which keys its new_suggestions (see noticeOf).
func (s *Service) StartScheduledBatch(ctx context.Context, week store.LocalDate) (Batch, error) {
if week.IsZero() || household.WeekStart(week) != week {
return Batch{}, fmt.Errorf("suggest: scheduled batch week %s is not a Monday", week)
}
return s.start(ctx, TriggerScheduled, week.String())
}
// start does StartBatch's work for a batch with trigger and the job week.
func (s *Service) start(ctx context.Context, trigger, week string) (Batch, error) {
var b Batch
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
var err error
b, err = s.startBatch(ctx, tx, trigger)
b, err = s.startBatch(ctx, tx, trigger, week)
return err
})
if err != nil {
@@ -228,10 +247,11 @@ func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
}
// startBatch does StartBatch's work in tx: the batch and the job that runs
// it are committed together, so a batch is never seen without its job. The
// caller wakes the job runner once tx has committed and Created is true.
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
// startBatch does StartBatch's work in tx, for a batch with trigger whose
// job has week (BatchJob.Week): the batch and the job that runs it are
// committed together, so a batch is never seen without its job. The caller
// wakes the job runner once tx has committed and Created is true.
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger, week string) (Batch, error) {
q := db.New(tx)
cur, err := s.activeBatch(ctx, q)
if err != nil {
@@ -250,7 +270,7 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
if err != nil {
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
}
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID, Week: week})
if err != nil {
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
}
@@ -428,7 +448,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
if left >= ReplenishBelow {
return nil
}
b, err := s.startBatch(ctx, tx, TriggerReplenish)
b, err := s.startBatch(ctx, tx, TriggerReplenish, "")
if err != nil {
return err
}
+178 -56
View File
@@ -300,6 +300,45 @@ func (e *env) runBatch(t *testing.T) batchResult {
return res
}
// scheduledWeek is the week the tests' scheduled batches are for: the
// Monday of 2026-W41.
var scheduledWeek = mustDate("2026-10-05")
func mustDate(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
panic(err)
}
return d
}
func mustZone(t *testing.T, name string) *time.Location {
t.Helper()
loc, err := household.LoadZone(name)
if err != nil {
t.Fatal(err)
}
return loc
}
// startBatch starts a batch with trigger, a scheduled one for
// scheduledWeek, and returns it with the key its new_suggestions has.
func (e *env) startBatch(t *testing.T, trigger string) (suggest.Batch, string) {
t.Helper()
if trigger == suggest.TriggerScheduled {
b, err := e.svc.StartScheduledBatch(t.Context(), scheduledWeek)
if err != nil {
t.Fatal(err)
}
return b, "new_suggestions:2026-W41"
}
b, err := e.svc.StartBatch(t.Context(), trigger)
if err != nil {
t.Fatal(err)
}
return b, "new_suggestions:" + b.ID
}
func (e *env) batchStatus(t *testing.T, id string) string {
t.Helper()
var status string
@@ -404,8 +443,9 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
}
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
// quests stores new_suggestions keyed by the batch, naming them; a
// replenish batch, or one that adds none, stores nothing.
// quests stores new_suggestions counting and naming them, keyed by the
// scheduled ISO week or the manual batch, for that week or the current
// one; a replenish batch, or one that adds none, stores nothing.
func TestBatchEmitsNewSuggestions(t *testing.T) {
for _, tc := range []struct {
trigger string
@@ -428,10 +468,7 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
)
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
if err != nil {
t.Fatal(err)
}
b, key := e.startBatch(t, tc.trigger)
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s", job.Status)
}
@@ -446,64 +483,70 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
}
r := rows[0]
if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" {
t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID)
week := household.WeekStart(store.LocalDateOf(time.Now().In(mustZone(t, "America/New_York")))).String()
if tc.trigger == suggest.TriggerScheduled {
week = scheduledWeek.String()
}
if r.Key != key || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
r.Payload.Title != "2 new Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week != week {
t.Errorf("new_suggestions row = %+v, want key %s counting and naming the Side quests, week %s, /discover", r, key, week)
}
})
}
}
// TestBatchNotifiesAsSideQuestsAreStored: new_suggestions is stored with
// the first round to add Side quests, so a batch whose later round fails
// still tells the household of those in the deck, once; a later round that
// succeeds adds no second notification; a replenish batch notifies nothing.
func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
// twoRoundBatch scripts a suggest_side_quests reply of BatchSize+1 Side
// quests, Fajita Dish A to F, whose first round grounds all but B, so a
// second round grounds F. Unless lastGrounds, normalize_recipe fails for
// F.
func (e *env) twoRoundBatch(t *testing.T, lastGrounds bool) {
t.Helper()
var quests []string
for i := range suggest.BatchSize + 1 {
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
e.search.Results[name] = nil
switch {
case i == suggest.BatchSize && !lastGrounds:
// A page is found, but no normalize_recipe reply is scripted
// for it, so the AI call grounding it fails.
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
e.search.Results[name] = []recipes.Candidate{{URL: page}}
e.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
case i != 1:
e.recipe(t, name)
}
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
}
e.reply(quests...)
}
// TestBatchNotifiesOnceWithEverySideQuestStored: a batch that fills across
// two grounding rounds stores new_suggestions once, when it completes,
// counting and naming every Side quest it stored, not just the first
// round's; a replenish batch notifies nothing.
func TestBatchNotifiesOnceWithEverySideQuestStored(t *testing.T) {
for _, tc := range []struct {
trigger string
lastFails bool
notify bool
trigger string
notify bool
}{
{suggest.TriggerScheduled, true, true},
{suggest.TriggerManual, true, true},
{suggest.TriggerManual, false, true},
{suggest.TriggerReplenish, true, false},
{suggest.TriggerScheduled, true},
{suggest.TriggerManual, true},
{suggest.TriggerReplenish, false},
} {
t.Run(fmt.Sprintf("%s last round fails %v", tc.trigger, tc.lastFails), func(t *testing.T) {
t.Run(tc.trigger, func(t *testing.T) {
e := newEnv(t)
e.start(t)
// The first round grounds BatchSize Side quests but B; the
// second round grounds F.
var quests []string
for i := range suggest.BatchSize + 1 {
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
e.search.Results[name] = nil
if i != 1 {
e.recipe(t, name)
}
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
}
e.reply(quests...)
if tc.lastFails {
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_f BEFORE INSERT ON dinners WHEN NEW.name = 'Fajita Dish F' BEGIN SELECT RAISE(ABORT, 'F refused'); END`); err != nil {
t.Fatal(err)
}
}
e.twoRoundBatch(t, true)
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
if err != nil {
t.Fatal(err)
b, key := e.startBatch(t, tc.trigger)
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s, want succeeded", job.Status)
}
job := e.waitJob(t, b.JobID)
want, deck := jobs.StatusSucceeded, suggest.BatchSize
if tc.lastFails {
want, deck = jobs.StatusFailed, suggest.BatchSize-1
}
if job.Status != want {
t.Fatalf("batch job %s, want %s", job.Status, want)
}
if got := e.deckNames(t); len(got) != deck {
t.Fatalf("deck = %v, want %d Side quests", got, deck)
deck := e.deckNames(t)
if want := []string{"Fajita Dish A", "Fajita Dish C", "Fajita Dish D", "Fajita Dish E", "Fajita Dish F"}; !slices.Equal(deck, want) {
t.Fatalf("deck = %v, want %v", deck, want)
}
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
if !tc.notify {
@@ -512,9 +555,47 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
}
return
}
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID ||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" {
t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID)
want := fmt.Sprintf("%d new Side quests are in", len(deck))
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != want ||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 2 more" {
t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, want)
}
})
}
}
// TestBatchFailingInALaterRoundStoresNothing: when normalize_recipe fails
// in a batch's second grounding round, a scheduled or manual batch stores
// none of the first round's Side quests and sends no new_suggestions, so
// no Side quest is left in the deck unannounced; a replenish batch, which
// never notifies, keeps the first round's in the deck.
func TestBatchFailingInALaterRoundStoresNothing(t *testing.T) {
for _, tc := range []struct {
trigger string
deck int
}{
{suggest.TriggerScheduled, 0},
{suggest.TriggerManual, 0},
{suggest.TriggerReplenish, suggest.BatchSize - 1},
} {
t.Run(tc.trigger, func(t *testing.T) {
e := newEnv(t)
e.start(t)
e.twoRoundBatch(t, false)
b, _ := e.startBatch(t, tc.trigger)
job := e.waitJob(t, b.JobID)
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != suggest.ReasonAIFailed {
t.Fatalf("batch job %s, error %+v, want failed %s", job.Status, job.Error, suggest.ReasonAIFailed)
}
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
t.Errorf("batch status = %s, want failed", got)
}
if got := e.deckNames(t); len(got) != tc.deck {
t.Errorf("deck = %v, want %d Side quests", got, tc.deck)
}
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 {
t.Errorf("new_suggestions rows = %+v, want none", rows)
}
})
}
@@ -1299,7 +1380,7 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
t.Errorf("batch status = %s with its job failed, want failed", got)
}
// Its Side quests stay in the deck, and the household was told of them.
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Body != "Chili Verde" {
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Title != "1 new Side quest is in" || rows[0].Payload.Body != "Chili Verde" {
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
}
}
@@ -1474,6 +1555,47 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) {
}
}
// TestScheduledBatchNeedsItsWeek: a scheduled batch is started only by
// StartScheduledBatch, for a Monday.
func TestScheduledBatchNeedsItsWeek(t *testing.T) {
e := newEnv(t)
if _, err := e.svc.StartBatch(t.Context(), suggest.TriggerScheduled); err == nil {
t.Error("StartBatch started a scheduled batch with no week")
}
for _, week := range []store.LocalDate{{}, mustDate("2026-10-06")} {
if _, err := e.svc.StartScheduledBatch(t.Context(), week); err == nil {
t.Errorf("StartScheduledBatch(%q) accepted a week that is not a Monday", week)
}
}
if n := e.activeBatches(t); n != 0 {
t.Errorf("%d active batches, want none", n)
}
}
// TestScheduledBatchKeyedByItsWeekWhenRunLate: a scheduled batch's
// new_suggestions is keyed by the week it was scheduled for, not the week
// it runs in, so a batch caught up after its week ended still tells the
// household of that week's Side quests and leaves the next week's key free.
func TestScheduledBatchKeyedByItsWeekWhenRunLate(t *testing.T) {
e := newEnv(t)
e.start(t)
e.recipe(t, "Chili Verde")
e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"))
// A week long past: the batch runs today, weeks later.
b, err := e.svc.StartScheduledBatch(t.Context(), mustDate("2026-01-05"))
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s", job.Status)
}
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
if len(rows) != 1 || rows[0].Key != "new_suggestions:2026-W02" || rows[0].Payload.Week != "2026-01-05" ||
rows[0].Payload.Title != "1 new Side quest is in" {
t.Errorf("new_suggestions rows = %+v, want one keyed new_suggestions:2026-W02 for 2026-01-05", rows)
}
}
// noCalendar is a plan.Calendar for tests that never push.
type noCalendar struct{}