Feature 19: Notifications - Web Push and Google Chat (#169)
CI / ci (push) Successful in 8m31s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #169.
This commit is contained in:
2026-10-09 18:04:05 -04:00
committed by approver_agent
parent 4b592fdbbb
commit dcd35e78ec
111 changed files with 11455 additions and 494 deletions
+15 -1
View File
@@ -10,6 +10,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -147,6 +148,12 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill
// draft_week was given: a day whose dinner is no longer in it, or is in it
// as a Regular rather than the Quest log or the other way round, leaves
// everything as it is, failing with ReasonPoolChanged.
//
// A stored draft emits plan_ready, keyed by the week, in the same
// transaction, naming the drafted dinners Monday to Sunday. The key is the week's,
// so only the AI's first draft of a week notifies: a redraft (Rebuild week)
// stores no second notification. A manual swap never drafts, so never
// notifies.
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
var planID string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -156,8 +163,10 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
return fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make(map[string]string, len(rows))
names := make(map[string]string, len(rows))
for _, d := range rows {
pool[d.ID] = d.Status
names[d.ID] = d.Name
}
for _, d := range week.Days {
status, ok := pool[d.DinnerID]
@@ -215,11 +224,16 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err)
}
}
return nil
planned := make([]string, len(week.Days))
for i, d := range week.Days {
planned[i] = names[d.DinnerID]
}
return s.notifier.EmitTx(ctx, tx, chat.PlanReady(weekStart, thisWeek, planned))
})
if err != nil {
return "", err
}
s.notifier.Wake()
return planID, nil
}
+33 -3
View File
@@ -22,10 +22,12 @@ import (
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/store"
)
@@ -138,6 +140,7 @@ type Service struct {
st *store.Store
draft Drafter
cal Calendar
notifier *notify.Service
logger *slog.Logger
draftJob *jobs.Kind[DraftJob]
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
@@ -146,11 +149,12 @@ type Service struct {
}
// NewService returns a service over st that drafts with draft, pushes to
// cal with links under publicURL, and registers the KindDraftWeek job on
// cal with links under publicURL, tells the household through notifier,
// which must be over st, and registers the KindDraftWeek job on
// runner, which must not have started yet. The kind is service-only:
// StartDraft, which keeps one draft in flight per week, is the only way to
// enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("plan: nil store")
@@ -160,12 +164,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
return nil, errors.New("plan: nil job runner")
case cal == nil:
return nil, errors.New("plan: nil calendar")
case notifier == nil:
return nil, errors.New("plan: nil notifier")
case publicURL == nil:
return nil, errors.New("plan: nil public URL")
case logger == nil:
return nil, errors.New("plan: nil logger")
}
s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL}
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, logger: logger, publicURL: publicURL}
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
if err != nil {
return nil, err
@@ -226,6 +232,30 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, e
return readPlan(ctx, db.New(tx), weekStart)
}
// EmitDayOfReminder emits day_of_reminder for date, keyed by the date,
// naming the dinner planned on it, and reports whether it did: a date whose
// week has no plan emits nothing. The scheduler calls it at the
// household's reminder_time (Feature 20); emitting a date again stores no
// second notification.
func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) {
p, err := s.Get(ctx, household.WeekStart(date))
if errors.Is(err, ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
for _, d := range p.Days {
if d.Date == date.String() {
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
return false, fmt.Errorf("plan: %w", err)
}
return true, nil
}
}
return false, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
}
// readPlan reads the plan of the week starting weekStart through q, with
// each day's start_by from the household's dinner time and zone.
func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) {
+83 -1
View File
@@ -20,11 +20,14 @@ import (
"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/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -143,7 +146,7 @@ func newEnv(t *testing.T) *env {
if err != nil {
t.Fatal(err)
}
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, publicURL, logger)
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), publicURL, logger)
if err != nil {
t.Fatal(err)
}
@@ -352,6 +355,85 @@ func TestDraftCreatesPlan(t *testing.T) {
}
}
// TestDraftEmitsPlanReady: the AI's first draft of a week stores plan_ready
// keyed by the week, on push and Google Chat, naming the dinners Monday to
// Sunday; a redraft (Rebuild week) and a manual swap store nothing more.
func TestDraftEmitsPlanReady(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
e.mustDraft(t, false)
zone, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
want := chat.PlanReady(week, household.WeekStart(household.Today(time.Now(), zone)), fullWeek)
rows := notifytest.Rows(t, e.st, notify.PlanReady)
if len(rows) != 2 || rows[0].Channel != notify.ChannelChat || rows[1].Channel != notify.ChannelPush {
t.Fatalf("plan_ready rows = %+v, want one on chat and one on push", rows)
}
for _, r := range rows {
p := r.Payload
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title)
}
}
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
e.reply("", reversed...)
e.mustDraft(t, false)
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil {
t.Fatal(err)
}
if again := notifytest.Rows(t, e.st, notify.PlanReady); !slices.Equal(again, rows) {
t.Errorf("plan_ready rows after a redraft and a swap = %+v, want the first draft's only", again)
}
}
// TestFailedDraftEmitsNothing: a draft that stores no plan notifies no one.
func TestFailedDraftEmitsNothing(t *testing.T) {
e := newEnv(t)
e.start(t)
e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
if job := e.draft(t, false); job.Status != jobs.StatusFailed {
t.Fatalf("job %s, want failed", job.Status)
}
if rows := notifytest.Rows(t, e.st, notify.PlanReady); len(rows) != 0 {
t.Errorf("plan_ready rows = %+v, want none", rows)
}
}
// TestEmitDayOfReminder: the reminder of a planned date names its dinner,
// keyed by the date; a date with no plan emits nothing, and a date emitted
// again stores nothing more.
func TestEmitDayOfReminder(t *testing.T) {
e := newEnv(t)
e.start(t)
if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); ok || err != nil {
t.Fatalf("EmitDayOfReminder with no plan = %v, %v, want false", ok, err)
}
e.reply("", fullWeek...)
e.mustDraft(t, false)
for range 2 {
if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); !ok || err != nil {
t.Fatalf("EmitDayOfReminder = %v, %v, want true", ok, err)
}
}
rows := notifytest.Rows(t, e.st, notify.DayOfReminder)
if len(rows) != 1 {
t.Fatalf("day_of_reminder rows = %+v, want one on push", rows)
}
r := rows[0]
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight's dinner" ||
r.Payload.Body != "Pad Thai" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
t.Errorf("day_of_reminder row = %+v", r)
}
}
func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) {
e := newEnv(t)
e.start(t)