Freeze ended weeks into the Supply archive and serve the archive API (Feature 17) #135

13 changed files with 1103 additions and 9 deletions
+33
View File
@@ -490,6 +490,25 @@ automatic** (D-021).
generation: the list stays untitled (the UI shows the week label), the
failure is logged as a warning and recorded in `ai_runs`, the job fails
`title_failed`, and the week's next generation tries again.
`shopping.Freeze` titles a list still untitled once more before
freezing it (see Supply archive), through the same skill, scope and
conditional write.
## Supply archive (Feature 17)
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
archive: `status` becomes `archived` and `frozen_at` is set, after which
generating the list or changing an item is 409 `archived`. It freezes only
a week that has ended in the household's time zone (its Monday is before
the Monday of the household's current week), so a week not yet ended is
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
frozen list does nothing. A list still untitled is titled first with
`archive_title` from the week's plan, as `Generate` titles it; a title
that cannot be written is logged and the list is frozen untitled, when its
week label stands in for good. A `title_supply_run` job still in flight
with the same scope shares that run (`harness.Run`); whichever stores the
title first wins, and a job storing after the freeze stores nothing. The
`week_rollover` schedule is to call `Freeze` for last week.
## Recipe grounding
@@ -790,6 +809,20 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
needed, needed_count, total_count}` with the list's counts after the
change. 404 for an unknown item, 409 `archived` when the list is in the
Supply archive, 400 `invalid_request` for a bad body.
- `GET /api/v1/shopping/archive?by=week|month|year` returns the Supply
archive `{by, periods: [{key, heading, posts: [{weekStart, label,
title, dinnerCount, itemCount, onHandCount, dinners, current}]}]}`: the
Supply run of every week up to the household's current week, newest
first, grouped by the Monday's ISO week (`key` "2026-W41", `heading` the
week label), month ("2026-10", "October 2026") or year ("2026"). The
Monday is a household-local date, so the week of Monday 28 December
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
UI sets it in capitals), `title` null while untitled, `dinners` the
list's dinners in day order and `dinnerCount` their number,
`itemCount` all items, `onHandCount` the items marked "have it", and
`current` true for the household's current week. 400 `invalid_request`
for any other `by`. A frozen list is read through `GET
/api/v1/shopping/{weekStart}` with `status` `archived`.
## Frontend
+6
View File
@@ -87,6 +87,9 @@ type Querier interface {
// Records a pending or running batch as done or failed; a batch already
// finished is left as it is (0 rows).
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
// Freezes list id into the Supply archive at frozen_at, unless it is
// frozen already.
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
GetAISettings(ctx context.Context) (AiSetting, error)
// The job of kind queued or running for the week starting week_start,
@@ -184,6 +187,9 @@ type Querier interface {
// The items of list list_id with their group's dinner, in list order: by
// group, then by item.
ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error)
// The Supply runs of the weeks starting on or before week_start, newest
// week first.
ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error)
// The ids of the dinners with status quest_log planned on date, on a plan
// of any status, that have no Rotation check for date yet.
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
+63
View File
@@ -112,6 +112,28 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
return err
}
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
UPDATE shopping_lists
SET status = 'archived', frozen_at = ?, updated_at = ?
WHERE id = ? AND frozen_at IS NULL
`
type FreezeShoppingListParams struct {
FrozenAt sql.NullString
UpdatedAt string
ID string
}
// Freezes list id into the Supply archive at frozen_at, unless it is
// frozen already.
func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) {
result, err := q.db.ExecContext(ctx, freezeShoppingList, arg.FrozenAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getShoppingItemList = `-- name: GetShoppingItemList :one
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at
FROM shopping_items
@@ -299,6 +321,47 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS
return items, nil
}
const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
WHERE week_start <= ?
ORDER BY week_start DESC
`
// The Supply runs of the weeks starting on or before week_start, newest
// week first.
func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error) {
rows, err := q.db.QueryContext(ctx, listShoppingListsThrough, weekStart)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShoppingList
for rows.Next() {
var i ShoppingList
if err := rows.Scan(
&i.ID,
&i.WeekStart,
&i.PlanID,
&i.Title,
&i.Status,
&i.SentToChatAt,
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveShoppingGroup = `-- name: MoveShoppingGroup :exec
UPDATE shopping_groups
SET position = ?, updated_at = ?
+2 -1
View File
@@ -37,6 +37,7 @@ type plansEnv struct {
h http.Handler
st *store.Store
svc *plan.Service
shop *shopping.Service
runner *jobs.Runner
questLog string
// google is the fake Google the plan service pushes to; it starts
@@ -127,7 +128,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Fatal(err)
}
}
return plansEnv{h: h, st: st, svc: svc, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
google: google, gcal: googleSvc, logs: logs}
}
+3 -1
View File
@@ -49,7 +49,8 @@ type RouteMounter interface {
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
// POST /api/v1/dinners/{id}/rotation-review, and POST
// /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/{weekStart},
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/archive,
// GET /api/v1/shopping/{weekStart},
// POST /api/v1/shopping/{weekStart}/generate and PATCH
// /api/v1/shopping/items/{id}; googleSvc serves GET
// /api/v1/integrations/google and its /connect, /callback and /calendars,
@@ -142,6 +143,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc))
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
+64
View File
@@ -143,6 +143,70 @@ func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerF
}
}
// supplyArchive is the GET /shopping/archive answer: the Supply archive's
// periods, newest first, as grouped by by.
type supplyArchive struct {
By string `json:"by"`
Periods []archivePeriod `json:"periods"`
}
// archivePeriod is one heading of the Supply archive and its posts,
// newest first.
type archivePeriod struct {
Key string `json:"key"`
Heading string `json:"heading"`
Posts []archivePost `json:"posts"`
}
// archivePost is one week's Supply run in the Supply archive. label is
// the week label, which stands in for title while it is null, and
// onHandCount counts the items marked "have it".
type archivePost struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
DinnerCount int `json:"dinnerCount"`
ItemCount int `json:"itemCount"`
OnHandCount int `json:"onHandCount"`
Dinners []string `json:"dinners"`
Current bool `json:"current"`
}
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
// the Supply archive grouped by that period.
func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
by := r.URL.Query().Get("by")
periods, err := svc.Archive(r.Context(), by)
if errors.Is(err, shopping.ErrInvalidPeriod) {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", shopping.ErrInvalidPeriod.Error())
return
}
if err != nil {
writeShoppingError(w, r, logger, "read Supply archive", err)
return
}
out := supplyArchive{By: by, Periods: make([]archivePeriod, len(periods))}
for i, p := range periods {
posts := make([]archivePost, len(p.Posts))
for j, post := range p.Posts {
posts[j] = archivePost{
WeekStart: post.WeekStart,
Label: post.Label,
Title: post.Title,
DinnerCount: len(post.Dinners),
ItemCount: post.Items,
OnHandCount: post.OnHand,
Dinners: post.Dinners,
Current: post.Current,
}
}
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
}
writeJSON(w, r, logger, http.StatusOK, out)
}
}
// supplyRunOf is l, the Supply run of the week starting week, as the API
// shows it.
func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
+153
View File
@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"slices"
@@ -11,9 +13,12 @@ import (
"testing"
"time"
"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/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -634,3 +639,151 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
t.Errorf("archived Supply run after push = %v, want unchanged", l.render())
}
}
// archiveJSON is a GET /shopping/archive answer decoded with every field
// the API promises.
type archiveJSON struct {
By string `json:"by"`
Periods []struct {
Key string `json:"key"`
Heading string `json:"heading"`
Posts []struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
DinnerCount int `json:"dinnerCount"`
ItemCount int `json:"itemCount"`
OnHandCount int `json:"onHandCount"`
Dinners []string `json:"dinners"`
Current bool `json:"current"`
} `json:"posts"`
} `json:"periods"`
}
// weekSupplyRun plans a new dinner named name, grounded in a recipe
// listing ings, on the Monday of the week starting monday, and generates
// the week's Supply run.
func (e plansEnv) weekSupplyRun(t *testing.T, monday store.LocalDate, name string, ings ...shopIng) supplyRunJSON {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular",
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
planID := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: monday.String(),
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
e.groundDay(t, monday.String(), ings...)
var l supplyRunJSON
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+monday.String()+"/generate", "", &l); status != http.StatusOK {
t.Fatalf("generate %s = %d %v", monday, status, body)
}
return l
}
// TestSupplyArchive freezes last week's Supply run and reads the Supply
// archive: posts newest first with exactly the promised fields, this
// week's marked current. The frozen list is served read-only.
func TestSupplyArchive(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
// The fixture's plan is for a fixed week; this test plans weeks
// around today instead.
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
t.Fatal(err)
}
zone, err := household.LoadZone("America/New_York") // the env's household zone
if err != nil {
t.Fatal(err)
}
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
y, m, d := thisWeek.Date()
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""}, shopIng{"onion", "1", ""})
onion := last.itemID(t, "onion")
if status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil); status != http.StatusOK {
t.Fatalf("PATCH = %d %v", status, body)
}
e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"chicken thighs", "6", ""}, shopIng{"lemon", "1", ""})
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
t.Fatal(err)
}
if err := e.shop.Freeze(t.Context(), thisWeek); !errors.Is(err, shopping.ErrWeekNotEnded) {
t.Fatalf("Freeze this week = %v, want ErrWeekNotEnded", err)
}
var got archiveJSON
status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got)
if status != http.StatusOK {
t.Fatalf("GET archive = %d %v", status, raw)
}
if k := keys(raw); !reflect.DeepEqual(k, []string{"by", "periods"}) {
t.Errorf("archive fields = %v", k)
}
period := raw["periods"].([]any)[0].(map[string]any)
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
t.Errorf("period fields = %v", k)
}
wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
t.Errorf("post fields = %v, want %v", k, wantPost)
}
var posts []string
for _, p := range got.Periods {
if len(p.Posts) != 1 || p.Key != household.PeriodKey(mustLocalDate(t, p.Posts[0].WeekStart)) || p.Heading != p.Posts[0].Label {
t.Errorf("period %+v, want one post under its week", p)
}
for _, post := range p.Posts {
label, err := household.WeekLabel(mustLocalDate(t, post.WeekStart))
if err != nil {
t.Fatal(err)
}
if post.Label != label || post.Title != nil {
t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label)
}
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
}
}
want := []string{
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
}
if got.By != "week" || !reflect.DeepEqual(posts, want) {
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
}
for _, q := range []string{"", "?by=", "?by=day", "?by=MONTH"} {
status, body := callShopping(t, e, http.MethodGet, shoppingPath+"archive"+q, "", nil)
if status != http.StatusBadRequest || errorCode(body) != "invalid_request" {
t.Errorf("GET archive%s = %d %v, want 400 invalid_request", q, status, body)
}
}
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), `{"needed":false}`, nil)
if status != http.StatusConflict || errorCode(body) != "archived" {
t.Errorf("PATCH after Freeze = %d %v, want 409 archived", status, body)
}
var frozen supplyRunJSON
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+lastWeek.String(), "", &frozen); status != http.StatusOK {
t.Fatalf("GET frozen list = %d %v", status, body)
}
wantLines := []string{
"Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed",
"Monday " + lastWeek.String() + " Beef Stew: carrots [4] needed",
"Monday " + lastWeek.String() + " Beef Stew: onion [1] have it",
}
if frozen.Status != "archived" || frozen.NeededCount != 2 || frozen.TotalCount != 3 || !reflect.DeepEqual(frozen.render(), wantLines) {
t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines)
}
}
+232
View File
@@ -0,0 +1,232 @@
package shopping
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"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/store"
)
// Archive groupings: the period Archive groups its posts by.
const (
ByWeek = "week"
ByMonth = "month"
ByYear = "year"
)
// Freeze moves the Supply run of the week starting weekStart into the
// Supply archive: its status becomes archived and frozen_at is set, after
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
// frozen list does nothing and is no error.
//
// The week must have ended in the household's time zone: a week not yet
// ended is ErrWeekNotEnded, and a week with no Supply run ErrListNotFound.
//
// A list still untitled is titled first, with archive_title and the same
// conditional write as a KindTitleSupplyRun job, from the week's plan as
// Generate titles it, so its post in the Supply archive gets a title when
// the AI can write one. Titling never fails the freeze: a title that
// cannot be written is logged and the list is frozen untitled, when its
// week label stands in for the title for good. Freeze first waits for the
// title jobs in flight to store their titles or fail, and holds off new
// ones until the list is frozen, so a title a job's archive_title run
// wrote is stored before the freeze, and the freeze runs archive_title
// only when no job is running it. A job that runs after the freeze finds
// the list titled, or frozen untitled, and stores nothing.
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
if _, err := household.WeekDays(weekStart); err != nil {
return fmt.Errorf("shopping: freeze: %w", err)
}
q := db.New(s.st.Writer)
thisWeek, err := s.thisWeek(ctx, q)
if err != nil {
return err
}
if weekStart.String() >= thisWeek.String() {
return fmt.Errorf("shopping: freeze %s: %w", weekStart, ErrWeekNotEnded)
}
s.titling.Lock()
defer s.titling.Unlock()
list, err := q.GetShoppingList(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return ErrListNotFound
}
if err != nil {
return fmt.Errorf("shopping: freeze: read list: %w", err)
}
if frozen(list) {
return nil
}
if !list.Title.Valid {
p, err := freezeTitleJob(ctx, q, list)
if err != nil {
return err
}
if _, err := s.storeTitle(ctx, p); err != nil {
s.logger.WarnContext(ctx, "Supply run frozen untitled; the Supply archive shows its week label",
"week", list.WeekStart, "list", list.ID, "error", err)
}
}
// A list frozen meanwhile is left as it is: the freeze is done.
now := store.Now().String()
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
}); err != nil {
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
}
return nil
}
// freezeTitleJob is the title job of list as Generate would enqueue it:
// the week's plan revision and its dinners in day order.
func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (TitleJob, error) {
plan, err := q.GetWeekPlan(ctx, list.WeekStart)
if err != nil {
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan: %w", err)
}
days, err := q.ListPlanDays(ctx, plan.ID)
if err != nil {
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan days: %w", err)
}
names := make([]string, len(days))
for i, d := range days {
names[i] = d.Dinner.Name
}
return TitleJob{ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: names}, nil
}
// thisWeek returns the Monday of the household's current week, in its
// time zone, by the service's clock.
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
}
return household.WeekStart(household.Today(s.now(), zone)), nil
}
// Period is one heading of the Supply archive and its posts, newest first.
type Period struct {
// Key identifies the period: the ISO week ("2026-W41"), the month
// ("2026-10") or the year ("2026").
Key string
// Heading is the period as shown: the week label, the month and year
// ("October 2026") or the year ("2026").
Heading string
Posts []Post
}
// Post is one Supply run in the Supply archive.
type Post struct {
WeekStart string
// Label is the week label, such as "Week 41 · Oct 5 – 11".
Label string
// Title is nil while the list is untitled, when Label stands in.
Title *string
// Dinners are the names of the list's dinners, in day order.
Dinners []string
// Items is how many items the list has, and OnHand how many of them
// the household marked "have it".
Items, OnHand int
// Current is true for the household's current week.
Current bool
}
// Archive returns the Supply archive: the Supply run of every week up to
// and including the household's current week, newest first, grouped by
// by, which is ByWeek, ByMonth or ByYear (else ErrInvalidPeriod). A
// week's month and year are those of its Monday, a household-local date,
// so a week belongs to one period whatever the server's zone; the week
// starting Monday 2026-12-28 is in December 2026.
func (s *Service) Archive(ctx context.Context, by string) (out []Period, err error) {
// period returns the key and heading of the period holding p, whose
// week starts on Monday monday.
var period func(monday store.LocalDate, p Post) (string, string)
switch by {
case ByWeek:
period = func(monday store.LocalDate, p Post) (string, string) {
return household.PeriodKey(monday), p.Label
}
case ByMonth:
period = func(monday store.LocalDate, _ Post) (string, string) {
y, m, _ := monday.Date()
return fmt.Sprintf("%04d-%02d", y, int(m)), fmt.Sprintf("%s %d", m, y)
}
case ByYear:
period = func(monday store.LocalDate, _ Post) (string, string) {
y, _, _ := monday.Date()
return strconv.Itoa(y), strconv.Itoa(y)
}
default:
return nil, fmt.Errorf("shopping: archive by %q: %w", by, ErrInvalidPeriod)
}
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("shopping: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
thisWeek, err := s.thisWeek(ctx, q)
if err != nil {
return nil, err
}
lists, err := q.ListShoppingListsThrough(ctx, thisWeek.String())
if err != nil {
return nil, fmt.Errorf("shopping: read Supply runs: %w", err)
}
out = []Period{}
for _, row := range lists {
monday, err := store.ParseLocalDate(row.WeekStart)
if err != nil {
return nil, fmt.Errorf("shopping: Supply run %s week: %w", row.ID, err)
}
l, err := readList(ctx, q, row)
if err != nil {
return nil, err
}
p, err := postOf(monday, l, monday == thisWeek)
if err != nil {
return nil, err
}
k, heading := period(monday, p)
if n := len(out); n == 0 || out[n-1].Key != k {
out = append(out, Period{Key: k, Heading: heading})
}
out[len(out)-1].Posts = append(out[len(out)-1].Posts, p)
}
return out, nil
}
// postOf is l, the Supply run of the week starting monday, as a post in
// the Supply archive.
func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
label, err := household.WeekLabel(monday)
if err != nil {
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
}
dinners := make([]string, len(l.Groups))
for i, g := range l.Groups {
dinners[i] = g.DinnerName
}
c := l.Counts()
return Post{
WeekStart: l.WeekStart,
Label: label,
Title: l.Title,
Dinners: dinners,
Items: c.Total,
OnHand: c.Total - c.Needed,
Current: current,
}, nil
}
+477
View File
@@ -0,0 +1,477 @@
package shopping_test
import (
"context"
"database/sql"
"errors"
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"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/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// zoned makes zone the household's time zone and at the instant the
// service's clock reads.
func (e *env) zoned(t *testing.T, zone, at string) {
t.Helper()
if err := household.SeedSettings(t.Context(), e.st, zone); err != nil {
t.Fatal(err)
}
e.at(t, at)
}
// at sets the service's clock to the RFC 3339 instant at.
func (e *env) at(t *testing.T, at string) {
t.Helper()
now, err := time.Parse(time.RFC3339, at)
if err != nil {
t.Fatal(err)
}
shopping.SetClock(e.svc, func() time.Time { return now })
}
// frozenState returns the week's list's status and frozen_at; frozen_at is
// "" while NULL.
func (e *env) frozenState(t *testing.T, monday store.LocalDate) (string, string) {
t.Helper()
var status string
var frozenAt sql.NullString
if err := e.st.Reader.QueryRow(`SELECT status, frozen_at FROM shopping_lists WHERE week_start = ?`, monday.String()).Scan(&status, &frozenAt); err != nil {
t.Fatal(err)
}
return status, frozenAt.String
}
// weekList plans the dinners named names on the days of the week starting
// monday, in order, and generates its Supply run.
func (e *env) weekList(t *testing.T, monday string, names ...string) shopping.List {
t.Helper()
start := mustDate(monday)
days, err := household.WeekDays(start)
if err != nil {
t.Fatal(err)
}
q := db.New(e.st.Writer)
now := store.Now().String()
planID := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for i, name := range names {
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{
ID: store.NewID(), PlanID: planID, Date: days[i].String(),
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
g, err := e.svc.Generate(t.Context(), start)
if err != nil {
t.Fatal(err)
}
return g.List
}
// endedWeek is the env with the tests' week generated, in New York, on
// the Monday after it, so the week has ended.
func endedWeek(t *testing.T) (*env, shopping.List) {
t.Helper()
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
e.plan(t, "Beef Chili")
return e, e.generate(t).List
}
// TestFreeze freezes an ended week's list: it is archived with frozen_at,
// read back read-only, and freezing it again changes nothing.
func TestFreeze(t *testing.T) {
e, l := endedWeek(t)
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
status, frozenAt := e.frozenState(t, week)
if status != shopping.StatusArchived || frozenAt == "" {
t.Fatalf("after Freeze: status %q, frozen_at %q; want archived and set", status, frozenAt)
}
got, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if got.Status != shopping.StatusArchived {
t.Errorf("read back status = %q, want archived", got.Status)
}
wantLines(t, "read back", lines(got), []string{
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
})
time.Sleep(2 * time.Millisecond) // a second freeze would stamp a later frozen_at
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatalf("second Freeze = %v, want no error", err)
}
if s, again := e.frozenState(t, week); s != shopping.StatusArchived || again != frozenAt {
t.Errorf("after second Freeze: status %q, frozen_at %q; want archived, %q kept", s, again, frozenAt)
}
if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[1].ID, false); !errors.Is(err, shopping.ErrFrozen) {
t.Errorf("SetNeeded after Freeze = %v, want ErrFrozen", err)
}
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) {
t.Errorf("Generate after Freeze = %v, want ErrFrozen", err)
}
wantLines(t, "stored list", e.stored(t), []string{
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
})
}
// TestFreezeWeekNotEnded: a week has not ended until Monday comes in the
// household's zone. At 23:30 on the Sunday in Los Angeles it is already
// Monday in UTC, and the list is still not frozen; at 00:05 on the Monday
// it is.
func TestFreezeWeekNotEnded(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/Los_Angeles", "2026-10-19T06:30:00Z") // Sun 18 Oct 23:30 PDT
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.generate(t)
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrWeekNotEnded) {
t.Fatalf("Freeze on the Sunday = %v, want ErrWeekNotEnded", err)
}
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusCurrent || frozenAt != "" {
t.Fatalf("after refused Freeze: status %q, frozen_at %q; want current, unset", status, frozenAt)
}
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrWeekNotEnded) {
t.Errorf("Freeze of a later week = %v, want ErrWeekNotEnded", err)
}
e.at(t, "2026-10-19T07:05:00Z") // Mon 19 Oct 00:05 PDT
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatalf("Freeze on the Monday = %v", err)
}
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
}
func TestFreezeWeekWithoutList(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) {
t.Fatalf("Freeze = %v, want ErrListNotFound", err)
}
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-13")); err == nil {
t.Fatal("Freeze of a Tuesday succeeded")
}
}
// TestFreezeTitlesUntitledList: a list still untitled when its week is
// frozen is titled by archive_title first, from the plan's dinners, and
// frozen with that title. Its title job, still queued, runs after the
// freeze, finds the title and succeeds without asking the model again.
func TestFreezeTitlesUntitledList(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.dinner(t, "Fish Tacos")
e.plan(t, "Fish Tacos", "Beef Chili")
g := e.generate(t) // the runner is not started: its title job waits
e.model.QueueGenerate(titled("Tacos then Chili"))
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
wantTitle(t, "stored title", e.storedTitle(t), "Tacos then Chili")
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusArchived || frozenAt == "" {
t.Errorf("status %q, frozen_at %q; want archived and set", status, frozenAt)
}
reqs := e.model.Requests()
if len(reqs) != 1 {
t.Fatalf("%d model calls, want 1", len(reqs))
}
if want := "Title the week whose dinners are, in day order from Monday:\n- Fish Tacos\n- Beef Chili"; reqs[0].Messages[0].Content != want {
t.Errorf("user message = %q, want %q", reqs[0].Messages[0].Content, want)
}
e.start(t)
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
if n := len(e.model.Requests()); n != 1 {
t.Errorf("%d model calls, want 1: the job finds the list titled", n)
}
wantTitle(t, "stored title after the job", e.storedTitle(t), "Tacos then Chili")
}
// pauseTitle makes the env's first archive_title run pause once it has
// returned, before its title is stored, until the returned release is
// called or the test ends; paused is closed as it pauses.
func (e *env) pauseTitle(t *testing.T) (paused <-chan struct{}, release func()) {
t.Helper()
p, r := make(chan struct{}), make(chan struct{})
var first atomic.Bool
e.afterTitle = func() {
if first.CompareAndSwap(false, true) {
close(p)
select {
case <-r:
case <-t.Context().Done():
}
}
}
var released sync.Once
return p, func() { released.Do(func() { close(r) }) }
}
// waitClosed fails t unless c is closed within 10 seconds.
func waitClosed(t *testing.T, what string, c <-chan struct{}) {
t.Helper()
select {
case <-c:
case <-time.After(10 * time.Second):
t.Fatalf("timed out waiting for %s", what)
}
}
// TestFreezeWaitsForTitleJob: a freeze that starts while a title job's
// archive_title run has returned but its title is not yet stored waits for
// the job to store it, freezes the list with that title, and asks the
// model nothing itself.
func TestFreezeWaitsForTitleJob(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.model.QueueGenerate(titled("Chili Night"))
paused, release := e.pauseTitle(t)
e.start(t)
g := e.generate(t)
waitClosed(t, "the title job's archive_title run", paused)
froze := make(chan error, 1)
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
var err error
select {
case err = <-froze:
t.Errorf("Freeze = %v before the title job stored its title, want it to wait", err)
release()
case <-time.After(time.Second):
release()
err = <-froze
}
if err != nil {
t.Fatal(err)
}
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
if n := len(e.model.Requests()); n != 1 {
t.Errorf("%d model calls, want 1: the freeze waits for the job's title", n)
}
}
// TestTitleJobWaitsForFreeze: a title job that starts while a freeze's
// archive_title run has returned but its title is not yet stored waits for
// the freeze, then finds the list titled and asks the model nothing.
func TestTitleJobWaitsForFreeze(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
e.plan(t, "Beef Chili")
e.model.QueueGenerate(titled("Chili Night"))
g := e.generate(t) // the runner is not started: its title job waits
paused, release := e.pauseTitle(t)
froze := make(chan error, 1)
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
waitClosed(t, "the freeze's archive_title run", paused)
e.start(t)
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusRunning {
break
}
if job.Status != jobs.StatusQueued || time.Now().After(deadline) {
t.Fatalf("title job %s, want it running while the freeze is paused", job.Status)
}
time.Sleep(5 * time.Millisecond)
}
release()
if err := <-froze; err != nil {
t.Fatal(err)
}
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
if n := len(e.model.Requests()); n != 1 {
t.Errorf("%d model calls, want 1: the job waits for the freeze's title", n)
}
}
// TestFreezeUntitledWhenTitleFails: when archive_title cannot title the
// list, it is frozen untitled all the same, the failure is logged, and the
// Supply archive shows its week label for good.
func TestFreezeUntitledWhenTitleFails(t *testing.T) {
e, _ := endedWeek(t) // nothing queued on the model: archive_title fails
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
wantUntitled(t, "stored title", e.storedTitle(t))
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
t.Errorf("status = %q, want archived", status)
}
if !strings.Contains(e.log.String(), "Supply run frozen untitled") {
t.Errorf("log lacks the failure:\n%s", e.log.String())
}
periods, err := e.svc.Archive(t.Context(), shopping.ByWeek)
if err != nil {
t.Fatal(err)
}
if len(periods) != 1 || len(periods[0].Posts) != 1 || periods[0].Posts[0].Title != nil ||
periods[0].Posts[0].Label != "Week 42 · Oct 12 – 18" {
t.Errorf("archive = %+v, want the untitled week under its label", periods)
}
}
// TestFreezeKeepsTitle: a titled list is frozen with its title and the
// model is not asked again.
func TestFreezeKeepsTitle(t *testing.T) {
e, _ := endedWeek(t)
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili Night'`); err != nil {
t.Fatal(err)
}
if err := e.svc.Freeze(t.Context(), week); err != nil {
t.Fatal(err)
}
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
if n := len(e.model.Requests()); n != 0 {
t.Errorf("%d model calls, want none", n)
}
}
// postLine renders p as "weekStart label | title | dinners | items/on hand
// | current".
func postLine(p shopping.Post) string {
title := "-"
if p.Title != nil {
title = *p.Title
}
cur := ""
if p.Current {
cur = " | current"
}
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
}
// periodLines renders periods one line per heading and post.
func periodLines(periods []shopping.Period) []string {
var out []string
for _, p := range periods {
out = append(out, "# "+p.Key+" "+p.Heading)
for _, post := range p.Posts {
out = append(out, postLine(post))
}
}
return out
}
// TestArchiveGroups reads the Supply archive across a month and a year
// boundary in Auckland, where it is already Monday 4 January 2027 while
// UTC still reads Sunday 3 January: that week is current, the next one is
// not yet in the archive, and each week falls in the month and year of
// its Monday.
func TestArchiveGroups(t *testing.T) {
e := newEnv(t)
e.zoned(t, "Pacific/Auckland", "2027-01-03T12:00:00Z") // Mon 4 Jan 2027 01:00 NZDT
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"})
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""})
e.dinner(t, "Fish Tacos") // no recipe: no group, not counted
e.dinner(t, "Pot Roast", ing{"carrots", "4", ""})
nov := e.weekList(t, "2026-11-30", "Pot Roast") // Nov 30 – Dec 6
e.weekList(t, "2026-12-07", "Pad Thai", "Fish Tacos")
dec := e.weekList(t, "2026-12-28", "Beef Chili", "Fish Tacos", "Pad Thai") // ISO 2026-W53, into January
jan := e.weekList(t, "2027-01-04", "Pad Thai", "Beef Chili")
e.weekList(t, "2027-01-11", "Pot Roast") // next week: not in the archive yet
e.haveIt(t, dec, "Beef Chili", "onion")
e.haveIt(t, dec, "Pad Thai", "lime")
e.haveIt(t, jan, "Pad Thai", "lime")
e.haveIt(t, nov, "Pot Roast", "carrots")
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil {
t.Fatal(err)
}
posts := map[string]string{
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
}
for _, tc := range []struct {
by string
want []string
}{
{shopping.ByWeek, []string{
"# 2027-W01 Week 1 · Jan 4 – 10", posts["jan"],
"# 2026-W53 Week 53 · Dec 28 – Jan 3", posts["dec"],
"# 2026-W50 Week 50 · Dec 7 – 13", posts["d07"],
"# 2026-W49 Week 49 · Nov 30 – Dec 6", posts["nov"],
}},
{shopping.ByMonth, []string{
"# 2027-01 January 2027", posts["jan"],
"# 2026-12 December 2026", posts["dec"], posts["d07"],
"# 2026-11 November 2026", posts["nov"],
}},
{shopping.ByYear, []string{
"# 2027 2027", posts["jan"],
"# 2026 2026", posts["dec"], posts["d07"], posts["nov"],
}},
} {
got, err := e.svc.Archive(t.Context(), tc.by)
if err != nil {
t.Fatalf("Archive(%s): %v", tc.by, err)
}
if lines := periodLines(got); !reflect.DeepEqual(lines, tc.want) {
t.Errorf("Archive(%s) =\n %s\nwant\n %s", tc.by, strings.Join(lines, "\n "), strings.Join(tc.want, "\n "))
}
}
}
func TestArchiveEmptyAndInvalidPeriod(t *testing.T) {
e := newEnv(t)
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
got, err := e.svc.Archive(t.Context(), shopping.ByMonth)
if err != nil || got == nil || len(got) != 0 {
t.Errorf("Archive of no Supply runs = %#v, %v; want empty", got, err)
}
for _, by := range []string{"", "day", "Week", "months"} {
if _, err := e.svc.Archive(t.Context(), by); !errors.Is(err, shopping.ErrInvalidPeriod) {
t.Errorf("Archive(%q) = %v, want ErrInvalidPeriod", by, err)
}
}
}
+6
View File
@@ -0,0 +1,6 @@
package shopping
import "time"
// SetClock makes now the clock s ends and dates weeks on.
func SetClock(s *Service, now func() time.Time) { s.now = now }
+42 -6
View File
@@ -8,6 +8,10 @@
// (see Generate). The one model call is the Supply run's title, written by
// archive_title in a KindTitleSupplyRun job when the list is generated
// untitled (see Generate).
//
// Freeze moves a week's Supply run into the Supply archive once its week
// has ended, and Archive reads the archive back as posts grouped by week,
// month or year.
package shopping
import (
@@ -19,6 +23,8 @@ import (
"fmt"
"log/slog"
"strings"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
@@ -70,6 +76,14 @@ var ErrListNotFound = errors.New("the week has no Supply run")
// ErrItemNotFound is a change to an item on no Supply run.
var ErrItemNotFound = errors.New("no Supply run has that item")
// ErrWeekNotEnded is a freeze of a Supply run whose week has not ended in
// the household's time zone.
var ErrWeekNotEnded = errors.New("the week has not ended")
// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and
// ByYear.
var ErrInvalidPeriod = errors.New("by must be week, month or year")
// Titler runs archive_title on in; scope names the week, its plan revision
// and its dinners.
// In production it is skills.TitleArchive with the household's
@@ -82,6 +96,14 @@ type Service struct {
title Titler
titleJob *jobs.Kind[TitleJob]
logger *slog.Logger
// now is the clock weeks are ended and current on; tests replace it.
now func() time.Time
// titling orders the title writes against Freeze. A title job holds it
// shared from reading the list to storing its title, and Freeze holds
// it exclusively from reading the list to freezing it, so a freeze
// waits for the title jobs in flight to store their titles or fail,
// and no title job runs archive_title while a freeze is under way.
titling sync.RWMutex
}
// NewService returns a service over st that titles Supply runs with title
@@ -98,7 +120,7 @@ func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog
case logger == nil:
return nil, errors.New("shopping: nil logger")
}
s := &Service{st: st, title: title, logger: logger}
s := &Service{st: st, title: title, logger: logger, now: time.Now}
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
if err != nil {
return nil, err
@@ -345,10 +367,19 @@ func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResul
return titleResult{ListID: p.ListID, Title: title}, nil
}
// writeTitle runs archive_title for p's Supply run and stores its title.
// When the list was titled by another job, before this one ran or
// meanwhile, that title stands and is returned.
// writeTitle runs archive_title for p's Supply run and stores its title,
// as storeTitle does, while no freeze is under way: it waits for one to
// end first, and a freeze waits for it to end.
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
s.titling.RLock()
defer s.titling.RUnlock()
return s.storeTitle(ctx, p)
}
// storeTitle runs archive_title for p's Supply run and stores its title.
// When the list was titled by another job, before this one ran or
// meanwhile, that title stands and is returned. The caller holds titling.
func (s *Service) storeTitle(ctx context.Context, p TitleJob) (string, error) {
q := db.New(s.st.Writer)
title, err := s.currentTitle(ctx, q, p)
if err != nil || title != "" {
@@ -423,13 +454,18 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e
if err != nil {
return List{}, fmt.Errorf("shopping: read list: %w", err)
}
return readList(ctx, q, list)
}
// readList reads the groups and items of list through q.
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
if err != nil {
return List{}, fmt.Errorf("shopping: read groups: %w", err)
return List{}, fmt.Errorf("shopping: read groups of %s: %w", list.WeekStart, err)
}
items, err := q.ListShoppingItems(ctx, list.ID)
if err != nil {
return List{}, fmt.Errorf("shopping: read items: %w", err)
return List{}, fmt.Errorf("shopping: read items of %s: %w", list.WeekStart, err)
}
byGroup := make(map[string][]Item, len(rows))
for _, r := range items {
+8 -1
View File
@@ -54,6 +54,9 @@ type env struct {
model *llm.Fake
// beforeTitle, when set, runs as archive_title is asked for a title.
beforeTitle func()
// afterTitle, when set, runs once archive_title has returned, before
// its title is stored.
afterTitle func()
// beforeModel, when set, runs as the model is sent a request.
beforeModel func(llm.Request)
// log holds what the service logged.
@@ -108,7 +111,11 @@ func newEnv(t *testing.T) *env {
if e.beforeTitle != nil {
e.beforeTitle()
}
return skills.TitleArchive(ctx, cfg, scope, in)
out, err := skills.TitleArchive(ctx, cfg, scope, in)
if e.afterTitle != nil {
e.afterTitle()
}
return out, err
}
logger := slog.New(slog.NewTextHandler(e.log, nil))
e.runner, err = jobs.NewRunner(st, logger, 2)
+14
View File
@@ -88,3 +88,17 @@ WHERE id = ?;
UPDATE shopping_lists
SET title = ?, updated_at = ?
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;
-- name: FreezeShoppingList :execrows
-- Freezes list id into the Supply archive at frozen_at, unless it is
-- frozen already.
UPDATE shopping_lists
SET status = 'archived', frozen_at = ?, updated_at = ?
WHERE id = ? AND frozen_at IS NULL;
-- name: ListShoppingListsThrough :many
-- The Supply runs of the weeks starting on or before week_start, newest
-- week first.
SELECT * FROM shopping_lists
WHERE week_start <= ?
ORDER BY week_start DESC;