Files
MealQuest/internal/shopping/archive.go
T
dev_agentandClaude Opus 5.5 6da5643f49
CI / ci (push) Successful in 5m4s
CI / ci (pull_request) Successful in 4m55s
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>
2026-10-08 07:10:06 -04:00

229 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}