Freeze ended weeks into the Supply archive and serve the archive API (Feature 17)
shopping.Freeze(weekStart) archives a week's Supply run once its week has
ended in the household's time zone, setting status archived and frozen_at;
it is idempotent, and a week not yet ended (ErrWeekNotEnded) or with no
list (ErrListNotFound) is an explicit error. A list still untitled is
titled first with archive_title and the same conditional write the
title_supply_run job uses; a failed title is logged and the list is
frozen untitled, keeping its week label.
GET /api/v1/shopping/archive?by=week|month|year returns the Supply runs
up to the household's current week, newest first, grouped by the
Monday's ISO week, month or year, each post with its week label, title or
null, dinner, item and on-hand counts, dinner names and current flag. Any
other by is 400 invalid_request. Frozen lists stay readable through
GET /shopping/{weekStart} and reject item PATCHes with 409 archived.
Maestro task 7ac36526-cb51-4dc7-bbad-6ebbee481942.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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. A title job still in
|
||||
// flight for the same plan revision and dinners shares Freeze's
|
||||
// archive_title run (see harness.Run), and whichever stores the title
|
||||
// first wins; a job whose write comes after the freeze 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)
|
||||
}
|
||||
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.writeTitle(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,359 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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")
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
@@ -70,6 +75,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 +95,8 @@ 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
|
||||
}
|
||||
|
||||
// NewService returns a service over st that titles Supply runs with title
|
||||
@@ -98,7 +113,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
|
||||
@@ -423,13 +438,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 {
|
||||
|
||||
Reference in New Issue
Block a user