Register the weekly suggestions schedule (Feature 20)
CI / ci (push) Successful in 9m25s
CI / ci (pull_request) Successful in 9m2s

suggest.Service.WeeklySchedule fires at the household suggestions_dow and
suggestions_time (default Sunday 08:00) with period key
suggestions_weekly:YYYY-Www and starts a scheduled batch through the same
service and job as the API, via the new StartScheduledBatch(week).
StartBatch now refuses the scheduled trigger.

new_suggestions is still emitted only where Feature 19 put it, in the
transaction that stores a batch's Side quests. A scheduled batch is now
keyed new_suggestions:YYYY-Www by the week carried in its job, so a
batch caught up late keeps its own week's key; a manual batch stays keyed
by its id. The title counts the new Side quests. A batch whose AI call
fails stores nothing and sends nothing; the failure is in ai_runs.

Fake-clock tests in internal/scheduler cover one batch and one
notification per week, settings-driven fire time, AI failure, restart,
late catch-up and a batch already under way.

Maestro task 459aab71-5c3d-4853-9855-236a3602b1e0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 19:51:16 -04:00
co-authored by Claude Opus 5.5
parent e5c13732d6
commit 94b1378a7b
9 changed files with 632 additions and 59 deletions
+3
View File
@@ -192,6 +192,9 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil { if err != nil {
return err 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()) 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 { if err != nil {
+23 -7
View File
@@ -775,7 +775,7 @@ purge are fixed and not configurable.
| Schedule | When | Does | | 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. | | `plan_draft` | Friday 09:00 | AI drafts next week's plan. |
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. | | `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 | Tonight's dinner reminder. |
@@ -803,6 +803,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 follows `household.LocalTime`: a skipped time fires later by the gap, a
repeated time fires once, at its first occurrence. 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 ## Notifications
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`, - Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
@@ -820,12 +830,18 @@ repeated time fires once, at its first occurrence.
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply - `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
run is first created, by `POST /shopping/{weekStart}/generate` or run is first created, by `POST /shopping/{weekStart}/generate` or
after Push to Calendar; remaking it does not notify. after Push to Calendar; remaking it does not notify.
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or - `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it
`manual` suggestion batch first adds Side quests to the deck, in the was scheduled for (carried in its job, so a batch caught up late is
transaction storing them, so they are announced even when a later still keyed by its own week), or `new_suggestions:{batchId}` for a
round or recording the batch's end fails; it names that round's Side `manual` one (URL `/discover`), when the batch first adds Side quests
quests, and a later round's emit stores nothing. A `replenish` batch to the deck, in the transaction storing them, so they are announced
never notifies. even when a later round or recording the batch's end fails. Its title
counts that round's Side quests ("3 new Side quests are in") and its
body names them; a later round's emit stores nothing. A batch that
fails before storing any, as when `suggest_side_quests` fails (the
failure is in `ai_runs`), sends nothing. 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 - `day_of_reminder:{date}` (URL `/dinner/{id}`) through
`plan.Service.EmitDayOfReminder(date)`, which names the dinner planned `plan.Service.EmitDayOfReminder(date)`, which names the dinner planned
on the date and emits nothing for a week with no plan. The scheduler 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, // NewSuggestions is the new_suggestions event keyed by id, the ISO week
// made in the week starting on week, naming its Side quests. // ("2026-W41") of a scheduled suggestion batch or the batch id of a manual
func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event { // 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{ return notify.Event{
Type: notify.NewSuggestions, Type: notify.NewSuggestions,
Key: string(notify.NewSuggestions) + ":" + batchID, Key: string(notify.NewSuggestions) + ":" + id,
Title: "New Side quests are in", Title: count(len(names), "new Side quest is in", "new Side quests are in"),
Body: nameList(names), Body: nameList(names),
URL: "/discover", URL: "/discover",
Week: week, Week: week,
+12 -2
View File
@@ -110,9 +110,19 @@ func TestMessages(t *testing.T) {
key: "new_suggestions:01J9BATCH", key: "new_suggestions:01J9BATCH",
url: "/discover", url: "/discover",
week: "2026-10-12", week: "2026-10-12",
title: "New Side quests are in", title: "5 new Side quests are in",
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more", 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", 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)
}
}
+57 -20
View File
@@ -22,6 +22,9 @@ import (
// BatchJob is the KindSuggestionBatch payload: the batch to run. // BatchJob is the KindSuggestionBatch payload: the batch to run.
type BatchJob struct { type BatchJob struct {
BatchID string `json:"batch_id"` 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. // Batch stages, as the job's result reports them.
@@ -57,8 +60,8 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// shutdown, the job is left running and the batch with it, for the next // shutdown, the job is left running and the batch with it, for the next
// start to fail. // start to fail.
// //
// A scheduled or manual batch emits new_suggestions, keyed by the batch, // A scheduled or manual batch emits new_suggestions (see noticeOf) in the
// in the transaction that first adds Side quests to the deck, so 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 // 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. // 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 // A replenish batch, which tops up the deck as the household swipes, never
@@ -78,7 +81,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
if n == 0 { if n == 0 {
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID) 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 == nil {
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil { if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
return batchResult{}, err return batchResult{}, err
@@ -98,10 +101,11 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
return batchResult{}, errors.Join(err, finishErr) return batchResult{}, errors.Join(err, finishErr)
} }
// fill does runBatch's work, returning a *jobs.Failure for a failure the // fill does runBatch's work for job of a batch with trigger, returning a
// household may see. When notifies is true, store emits new_suggestions // *jobs.Failure for a failure the household may see. Unless the batch is a
// with the Side quests it adds. // replenish one, 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) { 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{}} res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
if err := publish(res); err != nil { if err := publish(res); err != nil {
return batchResult{}, err return batchResult{}, err
@@ -110,6 +114,10 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
if err != nil { if err != nil {
return batchResult{}, err return batchResult{}, err
} }
note, err := noticeOf(trigger, job, in.Context.Today)
if err != nil {
return batchResult{}, err
}
out, err := s.suggest(ctx, batchID, in) out, err := s.suggest(ctx, batchID, in)
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
@@ -122,11 +130,6 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
if err := publish(res); err != nil { if err := publish(res); err != nil {
return batchResult{}, err return batchResult{}, err
} }
var week *store.LocalDate
if notifies {
w := household.WeekStart(in.Context.Today)
week = &w
}
left := out.Suggestions left := out.Suggestions
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 { for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))] round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
@@ -136,7 +139,7 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
return batchResult{}, err return batchResult{}, err
} }
res.Dropped += len(round) - len(grounded) res.Dropped += len(round) - len(grounded)
ids, err := s.store(ctx, batchID, week, grounded) ids, err := s.store(ctx, batchID, note, grounded)
if err != nil { if err != nil {
return batchResult{}, err return batchResult{}, err
} }
@@ -150,6 +153,40 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
return res, nil return res, 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, // 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 // the deck's names and every passed name (ListPassedNames). A household with no Regulars
// fails with ReasonNoRegulars. // fails with ReasonNoRegulars.
@@ -224,13 +261,13 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// or archived. Passed names are read in the same transaction as the store, // or archived. Passed names are read in the same transaction as the store,
// so a Pass or drop committed before it is never missed. // 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} // When note is not nil and a Side quest is stored, new_suggestions keyed by
// naming those stored is emitted in the same transaction, for the week // note, counting and naming those stored, is emitted in the same
// starting week, so the Side quests and their notification commit together // transaction, so the Side quests and their notification commit together
// or not at all: a notification that cannot be stored fails the store, and // 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 // 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. // 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) { func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) {
ids := []string{} ids := []string{}
var stored []string var stored []string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error { err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -298,15 +335,15 @@ func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDa
ids = append(ids, id) ids = append(ids, id)
stored = append(stored, d.Name) stored = append(stored, d.Name)
} }
if week == nil || len(stored) == 0 { if note == nil || len(stored) == 0 {
return nil 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 { if err != nil {
return nil, err return nil, err
} }
if week != nil && len(stored) > 0 { if note != nil && len(stored) > 0 {
s.notifier.Wake() s.notifier.Wake()
} }
return ids, nil 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/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "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/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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
@@ -154,17 +155,35 @@ type Batch struct {
Created bool `json:"-"` 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 // 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) { 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) { if !slices.Contains(triggers, trigger) {
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", 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 var b Batch
err := s.st.WithTx(ctx, func(tx *sql.Tx) error { err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
var err error var err error
b, err = s.startBatch(ctx, tx, trigger) b, err = s.startBatch(ctx, tx, trigger, week)
return err return err
}) })
if err != nil { 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 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 // startBatch does StartBatch's work in tx, for a batch with trigger whose
// it are committed together, so a batch is never seen without its job. The // job has week (BatchJob.Week): the batch and the job that runs it are
// caller wakes the job runner once tx has committed and Created is true. // committed together, so a batch is never seen without its job. The caller
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) { // 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) q := db.New(tx)
cur, err := s.activeBatch(ctx, q) cur, err := s.activeBatch(ctx, q)
if err != nil { if err != nil {
@@ -250,7 +270,7 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
if err != nil { if err != nil {
return Batch{}, fmt.Errorf("suggest: create batch: %w", err) 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 { if err != nil {
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err) 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 { if left >= ReplenishBelow {
return nil return nil
} }
b, err := s.startBatch(ctx, tx, TriggerReplenish) b, err := s.startBatch(ctx, tx, TriggerReplenish, "")
if err != nil { if err != nil {
return err return err
} }
+95 -16
View File
@@ -300,6 +300,45 @@ func (e *env) runBatch(t *testing.T) batchResult {
return res 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 { func (e *env) batchStatus(t *testing.T, id string) string {
t.Helper() t.Helper()
var status string var status string
@@ -404,8 +443,9 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
} }
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side // TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
// quests stores new_suggestions keyed by the batch, naming them; a // quests stores new_suggestions counting and naming them, keyed by the
// replenish batch, or one that adds none, stores nothing. // 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) { func TestBatchEmitsNewSuggestions(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
trigger string trigger string
@@ -428,10 +468,7 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"), sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"), sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
) )
b, err := e.svc.StartBatch(t.Context(), tc.trigger) b, key := e.startBatch(t, tc.trigger)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded { if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("batch job %s", job.Status) t.Fatalf("batch job %s", job.Status)
} }
@@ -446,9 +483,13 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
t.Fatalf("new_suggestions rows = %+v, want one on push", rows) t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
} }
r := rows[0] r := rows[0]
if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" || week := household.WeekStart(store.LocalDateOf(time.Now().In(mustZone(t, "America/New_York")))).String()
r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" { if tc.trigger == suggest.TriggerScheduled {
t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID) 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)
} }
}) })
} }
@@ -490,10 +531,7 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
} }
} }
b, err := e.svc.StartBatch(t.Context(), tc.trigger) b, key := e.startBatch(t, tc.trigger)
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, b.JobID) job := e.waitJob(t, b.JobID)
want, deck := jobs.StatusSucceeded, suggest.BatchSize want, deck := jobs.StatusSucceeded, suggest.BatchSize
if tc.lastFails { if tc.lastFails {
@@ -512,9 +550,9 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
} }
return return
} }
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != "4 new Side quests are in" ||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" { 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) t.Errorf("new_suggestions rows = %+v, want one keyed %s counting and naming the first round", rows, key)
} }
}) })
} }
@@ -1299,7 +1337,7 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
t.Errorf("batch status = %s with its job failed, want failed", got) 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. // 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) t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
} }
} }
@@ -1474,6 +1512,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. // noCalendar is a plan.Calendar for tests that never push.
type noCalendar struct{} type noCalendar struct{}