Freeze ended weeks into the Supply archive and serve the archive API (Feature 17) (#135)
CI / ci (push) Successful in 5m19s
CI / ci (push) Successful in 5m19s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #135.
This commit is contained in:
@@ -490,6 +490,25 @@ automatic** (D-021).
|
|||||||
generation: the list stays untitled (the UI shows the week label), the
|
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
|
failure is logged as a warning and recorded in `ai_runs`, the job fails
|
||||||
`title_failed`, and the week's next generation tries again.
|
`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
|
## 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
|
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
|
change. 404 for an unknown item, 409 `archived` when the list is in the
|
||||||
Supply archive, 400 `invalid_request` for a bad body.
|
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
|
## Frontend
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ type Querier interface {
|
|||||||
// Records a pending or running batch as done or failed; a batch already
|
// Records a pending or running batch as done or failed; a batch already
|
||||||
// finished is left as it is (0 rows).
|
// finished is left as it is (0 rows).
|
||||||
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
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.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||||
// The job of kind queued or running for the week starting week_start,
|
// 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
|
// The items of list list_id with their group's dinner, in list order: by
|
||||||
// group, then by item.
|
// group, then by item.
|
||||||
ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error)
|
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
|
// 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.
|
// of any status, that have no Rotation check for date yet.
|
||||||
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
|
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
|
||||||
|
|||||||
@@ -112,6 +112,28 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
|
|||||||
return err
|
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
|
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
|
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
|
FROM shopping_items
|
||||||
@@ -299,6 +321,47 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS
|
|||||||
return items, nil
|
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
|
const moveShoppingGroup = `-- name: MoveShoppingGroup :exec
|
||||||
UPDATE shopping_groups
|
UPDATE shopping_groups
|
||||||
SET position = ?, updated_at = ?
|
SET position = ?, updated_at = ?
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type plansEnv struct {
|
|||||||
h http.Handler
|
h http.Handler
|
||||||
st *store.Store
|
st *store.Store
|
||||||
svc *plan.Service
|
svc *plan.Service
|
||||||
|
shop *shopping.Service
|
||||||
runner *jobs.Runner
|
runner *jobs.Runner
|
||||||
questLog string
|
questLog string
|
||||||
// google is the fake Google the plan service pushes to; it starts
|
// 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)
|
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}
|
google: google, gcal: googleSvc, logs: logs}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ type RouteMounter interface {
|
|||||||
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
|
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
|
||||||
// POST /api/v1/dinners/{id}/rotation-review, and POST
|
// POST /api/v1/dinners/{id}/rotation-review, and POST
|
||||||
// /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the
|
// /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
|
// POST /api/v1/shopping/{weekStart}/generate and PATCH
|
||||||
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
||||||
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
// /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.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
|
||||||
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
|
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
|
||||||
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc))
|
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.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
|
||||||
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
|
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
|
||||||
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
|
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
|
||||||
|
|||||||
@@ -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
|
// supplyRunOf is l, the Supply run of the week starting week, as the API
|
||||||
// shows it.
|
// shows it.
|
||||||
func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
|
func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -11,9 +13,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
"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"
|
"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())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
// (see Generate). The one model call is the Supply run's title, written by
|
// (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
|
// archive_title in a KindTitleSupplyRun job when the list is generated
|
||||||
// untitled (see Generate).
|
// 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
|
package shopping
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -19,6 +23,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -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.
|
// ErrItemNotFound is a change to an item on no Supply run.
|
||||||
var ErrItemNotFound = errors.New("no Supply run has that item")
|
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
|
// Titler runs archive_title on in; scope names the week, its plan revision
|
||||||
// and its dinners.
|
// and its dinners.
|
||||||
// In production it is skills.TitleArchive with the household's
|
// In production it is skills.TitleArchive with the household's
|
||||||
@@ -82,6 +96,14 @@ type Service struct {
|
|||||||
title Titler
|
title Titler
|
||||||
titleJob *jobs.Kind[TitleJob]
|
titleJob *jobs.Kind[TitleJob]
|
||||||
logger *slog.Logger
|
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
|
// 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:
|
case logger == nil:
|
||||||
return nil, errors.New("shopping: nil logger")
|
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)
|
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return titleResult{ListID: p.ListID, Title: title}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeTitle runs archive_title for p's Supply run and stores its title.
|
// 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
|
// as storeTitle does, while no freeze is under way: it waits for one to
|
||||||
// meanwhile, that title stands and is returned.
|
// end first, and a freeze waits for it to end.
|
||||||
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
|
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)
|
q := db.New(s.st.Writer)
|
||||||
title, err := s.currentTitle(ctx, q, p)
|
title, err := s.currentTitle(ctx, q, p)
|
||||||
if err != nil || title != "" {
|
if err != nil || title != "" {
|
||||||
@@ -423,13 +454,18 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return List{}, fmt.Errorf("shopping: read list: %w", err)
|
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)
|
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
|
||||||
if err != nil {
|
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)
|
items, err := q.ListShoppingItems(ctx, list.ID)
|
||||||
if err != nil {
|
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))
|
byGroup := make(map[string][]Item, len(rows))
|
||||||
for _, r := range items {
|
for _, r := range items {
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ type env struct {
|
|||||||
model *llm.Fake
|
model *llm.Fake
|
||||||
// beforeTitle, when set, runs as archive_title is asked for a title.
|
// beforeTitle, when set, runs as archive_title is asked for a title.
|
||||||
beforeTitle func()
|
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, when set, runs as the model is sent a request.
|
||||||
beforeModel func(llm.Request)
|
beforeModel func(llm.Request)
|
||||||
// log holds what the service logged.
|
// log holds what the service logged.
|
||||||
@@ -108,7 +111,11 @@ func newEnv(t *testing.T) *env {
|
|||||||
if e.beforeTitle != nil {
|
if e.beforeTitle != nil {
|
||||||
e.beforeTitle()
|
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))
|
logger := slog.New(slog.NewTextHandler(e.log, nil))
|
||||||
e.runner, err = jobs.NewRunner(st, logger, 2)
|
e.runner, err = jobs.NewRunner(st, logger, 2)
|
||||||
|
|||||||
@@ -88,3 +88,17 @@ WHERE id = ?;
|
|||||||
UPDATE shopping_lists
|
UPDATE shopping_lists
|
||||||
SET title = ?, updated_at = ?
|
SET title = ?, updated_at = ?
|
||||||
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user