Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d19fb9545 | ||
|
|
fdfd0123fb |
@@ -490,31 +490,6 @@ automatic** (D-021).
|
||||
generation: the list stays untitled (the UI shows the week label), the
|
||||
failure is logged as a warning and recorded in `ai_runs`, the job fails
|
||||
`title_failed`, and the week's next generation tries again.
|
||||
`shopping.Freeze` titles a list still untitled once more before
|
||||
freezing it (see Supply archive), through the same skill, scope and
|
||||
conditional write.
|
||||
|
||||
## Supply archive (Feature 17)
|
||||
|
||||
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
|
||||
archive: `status` becomes `archived` and `frozen_at` is set, after which
|
||||
generating the list or changing an item is 409 `archived`. It freezes only
|
||||
a week that has ended in the household's time zone (its Monday is before
|
||||
the Monday of the household's current week), so a week not yet ended is
|
||||
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
|
||||
frozen list does nothing. A list still untitled is titled first with
|
||||
`archive_title` from the week's plan, as `Generate` titles it; a title
|
||||
that cannot be written is logged and the list is frozen untitled, when its
|
||||
week label stands in for good. Titling and freezing are ordered by a
|
||||
per-service `RWMutex`: a `title_supply_run` job holds it shared across
|
||||
reading the list, the `archive_title` run and the title write, and `Freeze`
|
||||
holds it exclusively from reading the list to freezing it. So a freeze
|
||||
waits for a job in flight to store its title or fail for good; when the
|
||||
job failed, the freeze finds the list still untitled and makes its own
|
||||
`archive_title` attempt before freezing. A job that starts during a freeze
|
||||
waits for it, then finds the list titled (that title stands) or frozen
|
||||
untitled (the job fails `title_failed`), and stores nothing. The
|
||||
`week_rollover` schedule is to call `Freeze` for last week.
|
||||
|
||||
## Recipe grounding
|
||||
|
||||
@@ -815,20 +790,6 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
needed, needed_count, total_count}` with the list's counts after the
|
||||
change. 404 for an unknown item, 409 `archived` when the list is in the
|
||||
Supply archive, 400 `invalid_request` for a bad body.
|
||||
- `GET /api/v1/shopping/archive?by=week|month|year` returns the Supply
|
||||
archive `{by, periods: [{key, heading, posts: [{weekStart, label,
|
||||
title, dinnerCount, itemCount, onHandCount, dinners, current}]}]}`: the
|
||||
Supply run of every week up to the household's current week, newest
|
||||
first, grouped by the Monday's ISO week (`key` "2026-W41", `heading` the
|
||||
week label), month ("2026-10", "October 2026") or year ("2026"). The
|
||||
Monday is a household-local date, so the week of Monday 28 December
|
||||
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
|
||||
UI sets it in capitals), `title` null while untitled, `dinners` the
|
||||
list's dinners in day order and `dinnerCount` their number,
|
||||
`itemCount` all items, `onHandCount` the items marked "have it", and
|
||||
`current` true for the household's current week. 400 `invalid_request`
|
||||
for any other `by`. A frozen list is read through `GET
|
||||
/api/v1/shopping/{weekStart}` with `status` `archived`.
|
||||
|
||||
## Frontend
|
||||
|
||||
|
||||
@@ -87,9 +87,6 @@ type Querier interface {
|
||||
// Records a pending or running batch as done or failed; a batch already
|
||||
// finished is left as it is (0 rows).
|
||||
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
// frozen already.
|
||||
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||
// The job of kind queued or running for the week starting week_start,
|
||||
@@ -187,9 +184,6 @@ type Querier interface {
|
||||
// The items of list list_id with their group's dinner, in list order: by
|
||||
// group, then by item.
|
||||
ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error)
|
||||
// The Supply runs of the weeks starting on or before week_start, newest
|
||||
// week first.
|
||||
ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error)
|
||||
// The ids of the dinners with status quest_log planned on date, on a plan
|
||||
// of any status, that have no Rotation check for date yet.
|
||||
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
|
||||
|
||||
@@ -112,28 +112,6 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
|
||||
UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
WHERE id = ? AND frozen_at IS NULL
|
||||
`
|
||||
|
||||
type FreezeShoppingListParams struct {
|
||||
FrozenAt sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
// frozen already.
|
||||
func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, freezeShoppingList, arg.FrozenAt, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getShoppingItemList = `-- name: GetShoppingItemList :one
|
||||
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at
|
||||
FROM shopping_items
|
||||
@@ -321,47 +299,6 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
|
||||
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
|
||||
WHERE week_start <= ?
|
||||
ORDER BY week_start DESC
|
||||
`
|
||||
|
||||
// The Supply runs of the weeks starting on or before week_start, newest
|
||||
// week first.
|
||||
func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listShoppingListsThrough, weekStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ShoppingList
|
||||
for rows.Next() {
|
||||
var i ShoppingList
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WeekStart,
|
||||
&i.PlanID,
|
||||
&i.Title,
|
||||
&i.Status,
|
||||
&i.SentToChatAt,
|
||||
&i.FrozenAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const moveShoppingGroup = `-- name: MoveShoppingGroup :exec
|
||||
UPDATE shopping_groups
|
||||
SET position = ?, updated_at = ?
|
||||
|
||||
@@ -37,7 +37,6 @@ type plansEnv struct {
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
svc *plan.Service
|
||||
shop *shopping.Service
|
||||
runner *jobs.Runner
|
||||
questLog string
|
||||
// google is the fake Google the plan service pushes to; it starts
|
||||
@@ -128,7 +127,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
|
||||
return plansEnv{h: h, st: st, svc: svc, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
|
||||
google: google, gcal: googleSvc, logs: logs}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,8 +49,7 @@ type RouteMounter interface {
|
||||
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
|
||||
// POST /api/v1/dinners/{id}/rotation-review, and POST
|
||||
// /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the
|
||||
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/archive,
|
||||
// GET /api/v1/shopping/{weekStart},
|
||||
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/{weekStart},
|
||||
// POST /api/v1/shopping/{weekStart}/generate and PATCH
|
||||
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
||||
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
||||
@@ -143,7 +142,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
|
||||
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
|
||||
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc))
|
||||
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
|
||||
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
|
||||
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
|
||||
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
|
||||
|
||||
@@ -143,70 +143,6 @@ func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerF
|
||||
}
|
||||
}
|
||||
|
||||
// supplyArchive is the GET /shopping/archive answer: the Supply archive's
|
||||
// periods, newest first, as grouped by by.
|
||||
type supplyArchive struct {
|
||||
By string `json:"by"`
|
||||
Periods []archivePeriod `json:"periods"`
|
||||
}
|
||||
|
||||
// archivePeriod is one heading of the Supply archive and its posts,
|
||||
// newest first.
|
||||
type archivePeriod struct {
|
||||
Key string `json:"key"`
|
||||
Heading string `json:"heading"`
|
||||
Posts []archivePost `json:"posts"`
|
||||
}
|
||||
|
||||
// archivePost is one week's Supply run in the Supply archive. label is
|
||||
// the week label, which stands in for title while it is null, and
|
||||
// onHandCount counts the items marked "have it".
|
||||
type archivePost struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
Title *string `json:"title"`
|
||||
DinnerCount int `json:"dinnerCount"`
|
||||
ItemCount int `json:"itemCount"`
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
|
||||
// the Supply archive grouped by that period.
|
||||
func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
by := r.URL.Query().Get("by")
|
||||
periods, err := svc.Archive(r.Context(), by)
|
||||
if errors.Is(err, shopping.ErrInvalidPeriod) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", shopping.ErrInvalidPeriod.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeShoppingError(w, r, logger, "read Supply archive", err)
|
||||
return
|
||||
}
|
||||
out := supplyArchive{By: by, Periods: make([]archivePeriod, len(periods))}
|
||||
for i, p := range periods {
|
||||
posts := make([]archivePost, len(p.Posts))
|
||||
for j, post := range p.Posts {
|
||||
posts[j] = archivePost{
|
||||
WeekStart: post.WeekStart,
|
||||
Label: post.Label,
|
||||
Title: post.Title,
|
||||
DinnerCount: len(post.Dinners),
|
||||
ItemCount: post.Items,
|
||||
OnHandCount: post.OnHand,
|
||||
Dinners: post.Dinners,
|
||||
Current: post.Current,
|
||||
}
|
||||
}
|
||||
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// supplyRunOf is l, the Supply run of the week starting week, as the API
|
||||
// shows it.
|
||||
func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -13,12 +11,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
@@ -639,151 +634,3 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
|
||||
t.Errorf("archived Supply run after push = %v, want unchanged", l.render())
|
||||
}
|
||||
}
|
||||
|
||||
// archiveJSON is a GET /shopping/archive answer decoded with every field
|
||||
// the API promises.
|
||||
type archiveJSON struct {
|
||||
By string `json:"by"`
|
||||
Periods []struct {
|
||||
Key string `json:"key"`
|
||||
Heading string `json:"heading"`
|
||||
Posts []struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
Title *string `json:"title"`
|
||||
DinnerCount int `json:"dinnerCount"`
|
||||
ItemCount int `json:"itemCount"`
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
} `json:"posts"`
|
||||
} `json:"periods"`
|
||||
}
|
||||
|
||||
// weekSupplyRun plans a new dinner named name, grounded in a recipe
|
||||
// listing ings, on the Monday of the week starting monday, and generates
|
||||
// the week's Supply run.
|
||||
func (e plansEnv) weekSupplyRun(t *testing.T, monday store.LocalDate, name string, ings ...shopIng) supplyRunJSON {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular",
|
||||
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: monday.String(),
|
||||
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.groundDay(t, monday.String(), ings...)
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+monday.String()+"/generate", "", &l); status != http.StatusOK {
|
||||
t.Fatalf("generate %s = %d %v", monday, status, body)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// TestSupplyArchive freezes last week's Supply run and reads the Supply
|
||||
// archive: posts newest first with exactly the promised fields, this
|
||||
// week's marked current. The frozen list is served read-only.
|
||||
func TestSupplyArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
// The fixture's plan is for a fixed week; this test plans weeks
|
||||
// around today instead.
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := household.LoadZone("America/New_York") // the env's household zone
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
|
||||
y, m, d := thisWeek.Date()
|
||||
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""}, shopIng{"onion", "1", ""})
|
||||
onion := last.itemID(t, "onion")
|
||||
if status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil); status != http.StatusOK {
|
||||
t.Fatalf("PATCH = %d %v", status, body)
|
||||
}
|
||||
e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"chicken thighs", "6", ""}, shopIng{"lemon", "1", ""})
|
||||
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.shop.Freeze(t.Context(), thisWeek); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Fatalf("Freeze this week = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
|
||||
var got archiveJSON
|
||||
status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("GET archive = %d %v", status, raw)
|
||||
}
|
||||
if k := keys(raw); !reflect.DeepEqual(k, []string{"by", "periods"}) {
|
||||
t.Errorf("archive fields = %v", k)
|
||||
}
|
||||
period := raw["periods"].([]any)[0].(map[string]any)
|
||||
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
|
||||
t.Errorf("period fields = %v", k)
|
||||
}
|
||||
wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
|
||||
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
|
||||
t.Errorf("post fields = %v, want %v", k, wantPost)
|
||||
}
|
||||
var posts []string
|
||||
for _, p := range got.Periods {
|
||||
if len(p.Posts) != 1 || p.Key != household.PeriodKey(mustLocalDate(t, p.Posts[0].WeekStart)) || p.Heading != p.Posts[0].Label {
|
||||
t.Errorf("period %+v, want one post under its week", p)
|
||||
}
|
||||
for _, post := range p.Posts {
|
||||
label, err := household.WeekLabel(mustLocalDate(t, post.WeekStart))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if post.Label != label || post.Title != nil {
|
||||
t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label)
|
||||
}
|
||||
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
|
||||
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
|
||||
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
|
||||
}
|
||||
if got.By != "week" || !reflect.DeepEqual(posts, want) {
|
||||
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
|
||||
}
|
||||
|
||||
for _, q := range []string{"", "?by=", "?by=day", "?by=MONTH"} {
|
||||
status, body := callShopping(t, e, http.MethodGet, shoppingPath+"archive"+q, "", nil)
|
||||
if status != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("GET archive%s = %d %v, want 400 invalid_request", q, status, body)
|
||||
}
|
||||
}
|
||||
|
||||
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), `{"needed":false}`, nil)
|
||||
if status != http.StatusConflict || errorCode(body) != "archived" {
|
||||
t.Errorf("PATCH after Freeze = %d %v, want 409 archived", status, body)
|
||||
}
|
||||
var frozen supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+lastWeek.String(), "", &frozen); status != http.StatusOK {
|
||||
t.Fatalf("GET frozen list = %d %v", status, body)
|
||||
}
|
||||
wantLines := []string{
|
||||
"Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed",
|
||||
"Monday " + lastWeek.String() + " Beef Stew: carrots [4] needed",
|
||||
"Monday " + lastWeek.String() + " Beef Stew: onion [1] have it",
|
||||
}
|
||||
if frozen.Status != "archived" || frozen.NeededCount != 2 || frozen.TotalCount != 3 || !reflect.DeepEqual(frozen.render(), wantLines) {
|
||||
t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
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,10 +8,6 @@
|
||||
// (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 (
|
||||
@@ -23,8 +19,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
@@ -76,14 +70,6 @@ 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
|
||||
@@ -96,14 +82,6 @@ type Service struct {
|
||||
title Titler
|
||||
titleJob *jobs.Kind[TitleJob]
|
||||
logger *slog.Logger
|
||||
// now is the clock weeks are ended and current on; tests replace it.
|
||||
now func() time.Time
|
||||
// titling orders the title writes against Freeze. A title job holds it
|
||||
// shared from reading the list to storing its title, and Freeze holds
|
||||
// it exclusively from reading the list to freezing it, so a freeze
|
||||
// waits for the title jobs in flight to store their titles or fail,
|
||||
// and no title job runs archive_title while a freeze is under way.
|
||||
titling sync.RWMutex
|
||||
}
|
||||
|
||||
// NewService returns a service over st that titles Supply runs with title
|
||||
@@ -120,7 +98,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, now: time.Now}
|
||||
s := &Service{st: st, title: title, logger: logger}
|
||||
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -367,19 +345,10 @@ func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResul
|
||||
return titleResult{ListID: p.ListID, Title: title}, nil
|
||||
}
|
||||
|
||||
// writeTitle runs archive_title for p's Supply run and stores its title,
|
||||
// as storeTitle does, while no freeze is under way: it waits for one to
|
||||
// end first, and a freeze waits for it to end.
|
||||
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
s.titling.RLock()
|
||||
defer s.titling.RUnlock()
|
||||
return s.storeTitle(ctx, p)
|
||||
}
|
||||
|
||||
// storeTitle runs archive_title for p's Supply run and stores its title.
|
||||
// writeTitle runs archive_title for p's Supply run and stores its title.
|
||||
// When the list was titled by another job, before this one ran or
|
||||
// meanwhile, that title stands and is returned. The caller holds titling.
|
||||
func (s *Service) storeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
// meanwhile, that title stands and is returned.
|
||||
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
q := db.New(s.st.Writer)
|
||||
title, err := s.currentTitle(ctx, q, p)
|
||||
if err != nil || title != "" {
|
||||
@@ -454,18 +423,13 @@ 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 of %s: %w", list.WeekStart, err)
|
||||
return List{}, fmt.Errorf("shopping: read groups: %w", err)
|
||||
}
|
||||
items, err := q.ListShoppingItems(ctx, list.ID)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read items of %s: %w", list.WeekStart, err)
|
||||
return List{}, fmt.Errorf("shopping: read items: %w", err)
|
||||
}
|
||||
byGroup := make(map[string][]Item, len(rows))
|
||||
for _, r := range items {
|
||||
|
||||
@@ -54,9 +54,6 @@ type env struct {
|
||||
model *llm.Fake
|
||||
// beforeTitle, when set, runs as archive_title is asked for a title.
|
||||
beforeTitle func()
|
||||
// afterTitle, when set, runs once archive_title has returned, before
|
||||
// its title is stored.
|
||||
afterTitle func()
|
||||
// beforeModel, when set, runs as the model is sent a request.
|
||||
beforeModel func(llm.Request)
|
||||
// log holds what the service logged.
|
||||
@@ -111,11 +108,7 @@ func newEnv(t *testing.T) *env {
|
||||
if e.beforeTitle != nil {
|
||||
e.beforeTitle()
|
||||
}
|
||||
out, err := skills.TitleArchive(ctx, cfg, scope, in)
|
||||
if e.afterTitle != nil {
|
||||
e.afterTitle()
|
||||
}
|
||||
return out, err
|
||||
return skills.TitleArchive(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(e.log, nil))
|
||||
e.runner, err = jobs.NewRunner(st, logger, 2)
|
||||
|
||||
@@ -88,17 +88,3 @@ WHERE id = ?;
|
||||
UPDATE shopping_lists
|
||||
SET title = ?, updated_at = ?
|
||||
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;
|
||||
|
||||
-- name: FreezeShoppingList :execrows
|
||||
-- Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
-- frozen already.
|
||||
UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
WHERE id = ? AND frozen_at IS NULL;
|
||||
|
||||
-- name: ListShoppingListsThrough :many
|
||||
-- The Supply runs of the weeks starting on or before week_start, newest
|
||||
-- week first.
|
||||
SELECT * FROM shopping_lists
|
||||
WHERE week_start <= ?
|
||||
ORDER BY week_start DESC;
|
||||
|
||||
@@ -8,7 +8,6 @@ import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
import { dinnerBody } from './test/dinners.ts'
|
||||
import { defaultWeek } from './plan/week.ts'
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
@@ -32,7 +31,6 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
const SCREENS = {
|
||||
'/onboarding': true,
|
||||
'/plan': true,
|
||||
'/plan/shopping': true,
|
||||
'/discover': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
@@ -107,29 +105,6 @@ describe('App routes', () => {
|
||||
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/plan/shopping renders the Supply run inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
const week = defaultWeek(HOUSEHOLD_SETTINGS, new Date())
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
[`/api/v1/shopping/${week}`]: () =>
|
||||
jsonResponse(200, {
|
||||
weekStart: week,
|
||||
label: 'Week 41 · Oct 5 – 11',
|
||||
title: null,
|
||||
status: 'current',
|
||||
sent_to_chat_at: null,
|
||||
needed_count: 1,
|
||||
total_count: 1,
|
||||
groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }],
|
||||
}),
|
||||
})
|
||||
renderAt('/plan/shopping')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
|
||||
})
|
||||
|
||||
it('/discover renders the Side quests deck inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
|
||||
@@ -16,8 +16,6 @@ import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
import { SupplyRunSync } from './shopping/api.ts'
|
||||
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
|
||||
|
||||
// Every route the app serves, with the heading its placeholder page shows.
|
||||
export const routes = [
|
||||
@@ -41,7 +39,6 @@ export const routes = [
|
||||
// The screens built so far; every other route shows its placeholder page.
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan': <PlanPage />,
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
@@ -89,7 +86,6 @@ export default function App({ api, serviceWorker }: AppProps) {
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthBootstrap>
|
||||
<SupplyRunSync />
|
||||
<AppRoutes />
|
||||
</AuthBootstrap>
|
||||
<OfflinePill />
|
||||
|
||||
@@ -20,7 +20,7 @@ export const meQueryKey = [ME_PATH] as const
|
||||
export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
const api = useApi()
|
||||
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: () => api.loadMe(), staleTime: Infinity, networkMode: 'always' })
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity, networkMode: 'always' })
|
||||
// fetch rejects with a TypeError only when the network fails.
|
||||
const savedPages = me.isError && me.error instanceof TypeError && api.session.verified()
|
||||
const { refetch } = me
|
||||
|
||||
@@ -125,66 +125,6 @@ describe('apiFetch', () => {
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('shares a csrf refresh between requests, but one whose signal aborts stops waiting and the next refreshes afresh', async () => {
|
||||
// The first refresh answers only when answerFirst is called; every other
|
||||
// request answers at once.
|
||||
let answerFirst!: (response: Response) => void
|
||||
const mes: (AbortSignal | null | undefined)[] = []
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
if (String(input) === '/api/v1/me') {
|
||||
mes.push(init?.signal)
|
||||
if (mes.length === 2) return new Promise((resolve) => (answerFirst = resolve))
|
||||
return Promise.resolve(jsonResponse(200, { ...ME, csrfToken: `token-${mes.length}` }))
|
||||
}
|
||||
const token = new Headers(init?.headers).get(CSRF_HEADER)
|
||||
return Promise.resolve(token === 'token-1' ? errorResponse(403, 'csrf') : jsonResponse(201, { token }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
|
||||
const unbounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })
|
||||
const timedOut = new AbortController()
|
||||
const bounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: timedOut.signal })
|
||||
await vi.waitFor(() => expect(mes).toHaveLength(2))
|
||||
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
|
||||
await expect(bounded).rejects.toMatchObject({ name: 'TimeoutError' })
|
||||
// A request without a signal still waits for it, so it is not aborted.
|
||||
expect(mes[1]!.aborted).toBe(false)
|
||||
|
||||
// A later csrf 403 does not wait on the refresh still in flight.
|
||||
await expect(
|
||||
api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: new AbortController().signal }),
|
||||
).resolves.toEqual({ token: 'token-3' })
|
||||
expect(mes).toHaveLength(3)
|
||||
|
||||
answerFirst(jsonResponse(200, { ...ME, csrfToken: 'token-2' }))
|
||||
await expect(unbounded).resolves.toEqual({ token: 'token-2' })
|
||||
expect(mes).toHaveLength(3)
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('aborts a csrf refresh once the only request waiting for it aborts, without counting it as offline', async () => {
|
||||
const mes: AbortSignal[] = []
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
if (String(input) !== '/api/v1/me') return Promise.resolve(errorResponse(403, 'csrf'))
|
||||
if (mes.push(init!.signal!) === 1) return Promise.resolve(jsonResponse(200, ME))
|
||||
return new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason as Error)))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const network = createNetworkStatus(window)
|
||||
const api = createApiClient(fakeLocation(), network, createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
|
||||
const timedOut = new AbortController()
|
||||
const bounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: timedOut.signal })
|
||||
await vi.waitFor(() => expect(mes).toHaveLength(2))
|
||||
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
|
||||
await expect(bounded).rejects.toMatchObject({ name: 'TimeoutError' })
|
||||
expect(mes[1]!.aborted).toBe(true)
|
||||
expect(network.offline()).toBe(false)
|
||||
})
|
||||
|
||||
it('marks any other 403 as not in this household and notifies subscribers', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
@@ -213,18 +153,6 @@ describe('apiFetch', () => {
|
||||
})
|
||||
|
||||
describe('loadMe', () => {
|
||||
it('aborts /me with its signal', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>((_, init) => new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason as Error)))),
|
||||
)
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const timedOut = new AbortController()
|
||||
const me = api.loadMe(timedOut.signal)
|
||||
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
|
||||
await expect(me).rejects.toMatchObject({ name: 'TimeoutError' })
|
||||
})
|
||||
|
||||
it('rejects a /me response without a CSRF token', async () => {
|
||||
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
|
||||
+15
-59
@@ -41,10 +41,6 @@ export type ApiRequest = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
// Sent as JSON. A GET must not have one.
|
||||
body?: unknown
|
||||
// Aborts the request, including the /me refresh a csrf 403 makes it wait
|
||||
// for; it then rejects with the signal's reason. No request has a deadline
|
||||
// unless its caller aborts it.
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
// ApiRead is a GET's decoded response body, and whether the service worker
|
||||
@@ -59,9 +55,8 @@ export type ApiClient = {
|
||||
// apiRead is apiFetch's GET that also says whether the body is the service
|
||||
// worker's cached copy rather than the server's answer.
|
||||
apiRead<T>(path: string): Promise<ApiRead<T>>
|
||||
// loadMe fetches GET /api/v1/me and caches its CSRF token; signal aborts
|
||||
// it, as it does an apiFetch.
|
||||
loadMe(signal?: AbortSignal): Promise<Me>
|
||||
// loadMe fetches GET /api/v1/me and caches its CSRF token.
|
||||
loadMe(): Promise<Me>
|
||||
// forbidden reports whether a request was refused because the signed-in
|
||||
// account is not in this household; subscribe is notified when it changes.
|
||||
forbidden(): boolean
|
||||
@@ -75,20 +70,15 @@ export type ApiClient = {
|
||||
session: VerifiedSession
|
||||
}
|
||||
|
||||
// Refresh is the /me request refreshMe shares: controller aborts it, waiting
|
||||
// counts the requests with a signal still waiting for it, and unbounded says
|
||||
// whether one without a signal is.
|
||||
type Refresh = { me: Promise<Me>; controller: AbortController; waiting: number; unbounded: boolean }
|
||||
|
||||
// createApiClient builds the app's API client. location is window.location in
|
||||
// the app: a 401 sends it to /auth/login with the current path to return to.
|
||||
export function createApiClient(location: ApiLocation, network: NetworkStatus, session: VerifiedSession): ApiClient {
|
||||
let csrfToken: string | null = null
|
||||
let refreshing: Refresh | null = null
|
||||
let refreshing: Promise<Me> | null = null
|
||||
let forbidden = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
async function send(method: string, path: string, body: unknown, signal: AbortSignal | undefined): Promise<Response> {
|
||||
async function send(method: string, path: string, body: unknown): Promise<Response> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (method !== 'GET') {
|
||||
@@ -107,7 +97,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
}
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json, signal })
|
||||
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json })
|
||||
} catch (error) {
|
||||
// fetch rejects with a TypeError when the network fails; an abort is a
|
||||
// DOMException and says nothing about the network.
|
||||
@@ -122,46 +112,12 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
}
|
||||
|
||||
// refreshMe shares one in-flight /me request between every request that
|
||||
// got a csrf 403 at the same time. A request whose signal aborts stops
|
||||
// waiting for it at once, and it is no longer shared: the next csrf 403
|
||||
// starts a fresh /me rather than wait on one that may never answer. Once
|
||||
// every request with a signal waiting for it has stopped, and none without
|
||||
// one waits, it is aborted.
|
||||
function refreshMe(signal: AbortSignal | undefined): Promise<Me> {
|
||||
if (refreshing === null) {
|
||||
const controller = new AbortController()
|
||||
const refresh: Refresh = { me: loadMe(controller.signal), controller, waiting: 0, unbounded: false }
|
||||
const done = () => {
|
||||
if (refreshing === refresh) refreshing = null
|
||||
}
|
||||
refresh.me.then(done, done)
|
||||
refreshing = refresh
|
||||
}
|
||||
const refresh = refreshing
|
||||
if (signal === undefined) {
|
||||
refresh.unbounded = true
|
||||
return refresh.me
|
||||
}
|
||||
refresh.waiting++
|
||||
return new Promise((resolve, reject) => {
|
||||
const stop = () => {
|
||||
if (refreshing === refresh) refreshing = null
|
||||
if (--refresh.waiting === 0 && !refresh.unbounded) refresh.controller.abort(signal.reason)
|
||||
reject(signal.reason as Error)
|
||||
}
|
||||
if (signal.aborted) return stop()
|
||||
signal.addEventListener('abort', stop, { once: true })
|
||||
refresh.me.then(
|
||||
(me) => {
|
||||
signal.removeEventListener('abort', stop)
|
||||
resolve(me)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', stop)
|
||||
reject(error as Error)
|
||||
},
|
||||
)
|
||||
// got a csrf 403 at the same time.
|
||||
function refreshMe(): Promise<Me> {
|
||||
refreshing ??= loadMe().finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
return refreshing
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
|
||||
@@ -182,14 +138,14 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
if (method === 'GET' && request.body !== undefined) {
|
||||
throw new Error(`GET ${path}: a GET request cannot carry a body`)
|
||||
}
|
||||
let response = await send(method, url, request.body, request.signal)
|
||||
let response = await send(method, url, request.body)
|
||||
if (response.status === 403) {
|
||||
const error = await parseForbidden(method, path, response)
|
||||
// Only a non-GET carries the token, so only it can be refused for it.
|
||||
if (error.code !== 'csrf' || method === 'GET') throw error
|
||||
// The session's token changed: refresh it once and retry once.
|
||||
await refreshMe(request.signal)
|
||||
response = await send(method, url, request.body, request.signal)
|
||||
await refreshMe()
|
||||
response = await send(method, url, request.body)
|
||||
if (response.status === 403) throw await parseForbidden(method, path, response)
|
||||
}
|
||||
return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) }
|
||||
@@ -220,8 +176,8 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
async function loadMe(signal?: AbortSignal): Promise<Me> {
|
||||
const me = await apiFetch<unknown>(ME_PATH, { signal })
|
||||
async function loadMe(): Promise<Me> {
|
||||
const me = await apiFetch<unknown>(ME_PATH)
|
||||
if (
|
||||
!isRecord(me) ||
|
||||
typeof me.name !== 'string' ||
|
||||
|
||||
@@ -258,8 +258,8 @@
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* An action with nothing behind it yet (Send to Google Chat until Feature
|
||||
19): it keeps focus, so hovering, tapping or focusing it shows the
|
||||
/* An action with nothing behind it yet (the shopping list until Feature
|
||||
17): it keeps focus, so hovering, tapping or focusing it shows the
|
||||
tooltip. */
|
||||
.unavailable {
|
||||
position: relative;
|
||||
@@ -414,21 +414,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The way to the week's Supply run under the Draft board on mobile. */
|
||||
.shoppingLink {
|
||||
display: inline-flex;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* A push failure's way to the Google Calendar settings. */
|
||||
.settingsLink {
|
||||
color: inherit;
|
||||
|
||||
+10
-158
@@ -1,6 +1,6 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
@@ -100,12 +100,8 @@ const lists = {
|
||||
jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }),
|
||||
}
|
||||
|
||||
// SUPPLY_RUN is the Supply run of a week, as GET /api/v1/shopping/{weekStart}.
|
||||
const SUPPLY_RUN = /^GET \/api\/v1\/shopping\/\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||
// the household settings, and no Supply run (404) for a week given none;
|
||||
// any other request fails the test.
|
||||
// the household settings; any other request fails the test.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
@@ -114,7 +110,7 @@ function stubServer(responders: Record<string, Responder>) {
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : SUPPLY_RUN.test(key) ? () => errorResponse(404, 'not_found') : undefined
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
@@ -143,37 +139,13 @@ async function renderPage() {
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<Routes>
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route path="/plan/shopping" element={<Opened />} />
|
||||
</Routes>
|
||||
<PlanPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// Opened stands in for the screen a link opened, saying where it is.
|
||||
function Opened() {
|
||||
const { pathname, search } = useLocation()
|
||||
return <p>Opened {pathname + search}</p>
|
||||
}
|
||||
|
||||
// supplyRun is the Supply run of the week of Oct 5 with needed of its 32
|
||||
// items still to buy.
|
||||
function supplyRun(needed: number) {
|
||||
return {
|
||||
weekStart: '2026-10-05',
|
||||
label: 'Week 41 · Oct 5 – 11',
|
||||
title: null,
|
||||
status: 'current',
|
||||
sent_to_chat_at: null,
|
||||
needed_count: needed,
|
||||
total_count: 32,
|
||||
groups: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
|
||||
// the page opens on this week.
|
||||
beforeEach(() => {
|
||||
@@ -757,85 +729,6 @@ describe('PlanPage', () => {
|
||||
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("links to the week's Supply run under the Draft board, saying how many items are still to buy", async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(19)),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
|
||||
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
expect(declaredStyle(shopping).color).toBe('var(--color-accent-700)')
|
||||
// It follows the push button and the sync status.
|
||||
const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
|
||||
expect(push.compareDocumentPosition(shopping) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
fireEvent.click(shopping)
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows a failed read of the week's shopping list with Retry, the link still opening the Supply run", async () => {
|
||||
let failing = true
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
|
||||
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
// Retry reads it again.
|
||||
failing = false
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
|
||||
fireEvent.click(shopping)
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("drops the count when reading the week's shopping list again fails, showing the failure with Retry", async () => {
|
||||
let failing = false
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))),
|
||||
})
|
||||
await renderPage()
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
|
||||
|
||||
// Coming back to the page once the Supply run is stale reads it again,
|
||||
// and that read fails.
|
||||
failing = true
|
||||
vi.setSystemTime(new Date('2026-10-07T16:01:00Z'))
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
|
||||
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
expect(screen.queryByRole('link', { name: 'Shopping list · 19' })).not.toBeInTheDocument()
|
||||
// Retry reads it again, and the count is back.
|
||||
failing = false
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links to the Supply run with no count while the week has no shopping list', async () => {
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
await waitFor(() => expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain('/api/v1/shopping/2026-10-05'))
|
||||
const shopping = screen.getByRole('link', { name: 'Shopping list' })
|
||||
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
// No list is not a failure.
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
fireEvent.click(shopping)
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves between weeks with the previous and next week buttons', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
@@ -1133,11 +1026,11 @@ describe.each([
|
||||
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
|
||||
|
||||
// Actions: the shopping list, with no count while the week has no
|
||||
// Supply run, and Rebuild week outlined; Push to Calendar primary.
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list' })
|
||||
expect(shopping).not.toHaveAttribute('aria-disabled')
|
||||
expect(shopping).not.toHaveAccessibleDescription()
|
||||
// Actions: the shopping list unavailable, saying why; Rebuild week
|
||||
// outlined and Push to Calendar primary, both working.
|
||||
const shopping = screen.getByRole('button', { name: 'Shopping list' })
|
||||
expect(shopping).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
|
||||
const push = screen.getByRole('button', { name: 'Push to Calendar' })
|
||||
expect(push).toBeEnabled()
|
||||
expect(push).not.toHaveAttribute('aria-disabled')
|
||||
@@ -1145,6 +1038,7 @@ describe.each([
|
||||
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
|
||||
expect(rebuild).toBeEnabled()
|
||||
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
|
||||
fireEvent.click(shopping)
|
||||
expect(posts(fetchMock)).toEqual([])
|
||||
// Mobile's own pieces are not shown.
|
||||
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
|
||||
@@ -1204,48 +1098,6 @@ describe.each([
|
||||
expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago')
|
||||
})
|
||||
|
||||
it("shows a failed read of the week's shopping list with Retry, the action still opening the Supply run", async () => {
|
||||
let failing = true
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(28))),
|
||||
...lists,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
|
||||
expect(screen.getByText('Spaghetti bolognese')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
failing = false
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
fireEvent.click(shopping)
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("opens the week's Supply run from the Shopping list action, saying how many items are still to buy", async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(28)),
|
||||
...lists,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
|
||||
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
expect(declaredStyle(shopping).borderColor).toBe('var(--color-neutral-300)')
|
||||
// Another week's action opens that week's, which has no list yet.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-12')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
fireEvent.click(await screen.findByRole('link', { name: 'Shopping list · 28' }))
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says changes to a pushed plan are not on Google Calendar', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists })
|
||||
await renderPage()
|
||||
|
||||
+15
-41
@@ -7,10 +7,8 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button, type ButtonVariant } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
@@ -81,6 +79,10 @@ const settingsFailures: Record<string, { reason: string; fix: string }> = {
|
||||
},
|
||||
}
|
||||
|
||||
// SHOPPING_HINT is why the shopping list is unavailable.
|
||||
// TODO(Feature 17): open the Supply run, with its count, once it is built.
|
||||
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
|
||||
|
||||
// syncStatuses is the plan manager's line on whether the plan is on Calendar.
|
||||
const syncStatuses: Record<PlanStatus, string> = {
|
||||
draft: 'Not pushed to Google Calendar yet',
|
||||
@@ -193,22 +195,6 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone
|
||||
)
|
||||
}
|
||||
|
||||
// ShoppingList opens the week's Supply run: an outline action in the plan
|
||||
// manager's header, a grape link under the Draft board on mobile. Once the
|
||||
// week has a Supply run it says how many items are still to buy, "Shopping
|
||||
// list · 28"; until then, while that is read, or when reading it failed, it
|
||||
// says no count, and the Supply run screen offers to make the list or shows
|
||||
// the failure.
|
||||
function ShoppingList({ weekStart, neededCount, layout }: { weekStart: string; neededCount: number | undefined; layout: Breakpoint }) {
|
||||
const className = layout === 'mobile' ? styles.shoppingLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
|
||||
return (
|
||||
<Link to={supplyRunScreen(weekStart)} className={className}>
|
||||
<Icon icon={ShoppingCart} size="inline" />
|
||||
{neededCount !== undefined ? `Shopping list · ${neededCount}` : 'Shopping list'}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// SkeletonTiles stands in for the seven days while the plan loads or the AI
|
||||
// drafts it, as two columns of tiles on mobile and seven columns wider.
|
||||
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
|
||||
@@ -231,10 +217,9 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
|
||||
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
|
||||
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
|
||||
//
|
||||
// From tablet up it is the plan manager (mocks 2c, 2d): Shopping list,
|
||||
// Rebuild week and Push to Calendar are actions in the header, over the
|
||||
// plan's sync status and the 7-column grid. On mobile the Shopping list
|
||||
// link sits under the push button.
|
||||
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to
|
||||
// Calendar and the unavailable Shopping list are actions in the header,
|
||||
// over the plan's sync status and the 7-column grid.
|
||||
//
|
||||
// Push to Calendar, on either layout, sends the plan shown and shows it as
|
||||
// synced once the server answers; a failure says why, linking to the Google
|
||||
@@ -258,7 +243,6 @@ function WeekBoard({
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const swap = useSwapDay(weekStart)
|
||||
const push = usePushToCalendar(weekStart)
|
||||
const run = useSupplyRun(weekStart)
|
||||
const pushing = push.pending
|
||||
// The date of the day being swapped, while the swap sheet is open.
|
||||
const [swappingDate, setSwappingDate] = useState<string | null>(null)
|
||||
@@ -376,16 +360,6 @@ function WeekBoard({
|
||||
) : (
|
||||
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
|
||||
)
|
||||
// A failed read of the week's Supply run is shown, with Retry, after the
|
||||
// Draft board's Shopping list link or under the plan manager's board; the
|
||||
// Shopping list action still opens the Supply run. The count is that of a
|
||||
// successful read only: a failed refetch keeps the earlier Supply run in
|
||||
// run.data, and its count is then no longer current.
|
||||
const neededCount = run.isSuccess ? run.data?.needed_count : undefined
|
||||
const shoppingList = <ShoppingList weekStart={weekStart} neededCount={neededCount} layout={layout} />
|
||||
const supplyRunFailure = run.isError && (
|
||||
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
// swapIn sends the swap chosen in the sheet and closes it.
|
||||
function swapIn(chosen: Swap) {
|
||||
@@ -488,8 +462,6 @@ function WeekBoard({
|
||||
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
|
||||
</Button>
|
||||
<SyncStatus plan={plan.data} className={styles.mobileStatus} />
|
||||
{shoppingList}
|
||||
{supplyRunFailure}
|
||||
{sheet}
|
||||
</>
|
||||
)
|
||||
@@ -521,7 +493,10 @@ function WeekBoard({
|
||||
<div className={styles.actionBar}>
|
||||
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
|
||||
<div className={styles.actions}>
|
||||
{shoppingList}
|
||||
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
|
||||
<Icon icon={ShoppingCart} size="inline" />
|
||||
Shopping list
|
||||
</Unavailable>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={styles.action}
|
||||
@@ -562,7 +537,6 @@ function WeekBoard({
|
||||
{header}
|
||||
{body}
|
||||
{failure}
|
||||
{layout !== 'mobile' && supplyRunFailure}
|
||||
<Confirm
|
||||
open={confirming}
|
||||
title="Rebuild this week?"
|
||||
@@ -626,10 +600,10 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
|
||||
)
|
||||
}
|
||||
|
||||
// Unavailable is an action with nothing behind it yet (Send to Google
|
||||
// Chat): it stays focusable, so a tap or focus shows the tooltip saying
|
||||
// why, above it or, at the top of the page, below.
|
||||
export function Unavailable({
|
||||
// Unavailable is an action with nothing behind it yet (the shopping list):
|
||||
// it stays focusable, so a tap or focus shows the tooltip saying why, above
|
||||
// it or, at the top of the page, below.
|
||||
function Unavailable({
|
||||
hint,
|
||||
variant = 'primary',
|
||||
below = false,
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
/* Supply run · shopping list (mocks 1l, 2b, 2f). */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
max-width: 440px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* Still to buy / all items: at the right of the title on mobile, beside it
|
||||
from tablet up, where the actions take the right. */
|
||||
.count {
|
||||
margin: 0 0 0 auto;
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tablet .count,
|
||||
.desktop .count {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.helper {
|
||||
margin: 6px 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action {
|
||||
min-height: 44px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* One card per dinner: stacked on mobile, three masonry columns from
|
||||
tablet up. */
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tablet .groups,
|
||||
.desktop .groups {
|
||||
display: block;
|
||||
column-count: 3;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.group {
|
||||
box-sizing: border-box;
|
||||
padding: 16px 18px 10px;
|
||||
border-radius: 24px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.tablet .group,
|
||||
.desktop .group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dinner {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.day {
|
||||
flex: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.items {
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* The checkbox's label fills the row, so the whole row up to the quantity
|
||||
is the hit area. */
|
||||
.check {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* An archived item's mark: the checkbox's look, not interactive. */
|
||||
.box {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid var(--color-neutral-400);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.item:not(.have) .box {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.have .name {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* Toggled while the server cannot be reached: the change is saved here and
|
||||
sent once it can be. */
|
||||
.willSync {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.qty {
|
||||
flex: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-700);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.have .qty {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.archiveLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skeletonTitle {
|
||||
width: 220px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.skeletonGroup {
|
||||
height: 160px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.tablet .skeletonGroup,
|
||||
.desktop .skeletonGroup {
|
||||
margin-bottom: 16px;
|
||||
break-inside: avoid;
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { sentAt, SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
|
||||
const GENERATE = `${THIS_WEEK}/generate`
|
||||
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
|
||||
|
||||
type Item = { id: string; name: string; qty: string | null; needed: boolean }
|
||||
|
||||
// The week of Oct 5's Supply run, as mock 1l starts it: Monday's tacos and
|
||||
// Wednesday's tikka masala, each with one item the household has.
|
||||
const GROUPS: { dinner: { id: string; name: string }; date: string; day: string; items: Item[] }[] = [
|
||||
{
|
||||
dinner: { id: 'd0', name: 'Beef tacos' },
|
||||
date: '2026-10-05',
|
||||
day: 'Monday',
|
||||
items: [
|
||||
{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true },
|
||||
{ id: 'i2', name: 'Corn tortillas', qty: '12', needed: true },
|
||||
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
dinner: { id: 'd2', name: 'Crock pot tikka masala' },
|
||||
date: '2026-10-07',
|
||||
day: 'Wednesday',
|
||||
items: [
|
||||
{ id: 'i4', name: 'Chicken thighs', qty: '2 lb', needed: true },
|
||||
{ id: 'i5', name: 'Tikka masala paste', qty: '1 jar', needed: true },
|
||||
{ id: 'i6', name: 'Basmati rice', qty: null, needed: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// supplyRun is the week's Supply run as GET /api/v1/shopping/{weekStart}
|
||||
// sends it, counted from its items.
|
||||
function supplyRun(overrides: Record<string, unknown> = {}, weekStart = '2026-10-05', label = 'Week 41 · Oct 5 – 11') {
|
||||
const items = GROUPS.flatMap((group) => group.items)
|
||||
return {
|
||||
weekStart,
|
||||
label,
|
||||
title: null,
|
||||
status: 'current',
|
||||
sent_to_chat_at: null,
|
||||
needed_count: items.filter((item) => item.needed).length,
|
||||
total_count: items.length,
|
||||
groups: GROUPS,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// deferred is a response the test sends when it chooses.
|
||||
function deferred() {
|
||||
let send!: (response: Response) => void
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
send = resolve
|
||||
})
|
||||
return { response, send }
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||
// the household settings; any other request fails the test.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// writes lists the PATCHes and POSTs sent, as "METHOD path body".
|
||||
function writes(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => init?.method === 'PATCH' || init?.method === 'POST')
|
||||
.map(([input, init]) => `${init!.method} ${String(input)} ${String(init?.body)}`)
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Supply run at path.
|
||||
async function renderPage(path = '/plan/shopping') {
|
||||
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<SupplyRunPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
|
||||
// the page opens on this week.
|
||||
beforeEach(() => {
|
||||
mockViewport(390)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
function checkbox(name: string) {
|
||||
return screen.getByRole('checkbox', { name })
|
||||
}
|
||||
|
||||
function row(name: string) {
|
||||
return screen.getByText(name).closest('li')!
|
||||
}
|
||||
|
||||
describe('SupplyRunPage', () => {
|
||||
it('renders mock 1l: kicker, title, the count to buy of all items and the helper', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
|
||||
await renderPage()
|
||||
|
||||
const title = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
|
||||
expect(declaredStyle(title).fontFamily).toBe('var(--font-heading)')
|
||||
// The kicker is the week's dates, set in capitals by the stylesheet.
|
||||
const kicker = screen.getByText('Supply run · Oct 5 – 11')
|
||||
expect(declaredStyle(kicker).textTransform).toBe('uppercase')
|
||||
expect(declaredStyle(kicker).color).toBe('var(--color-accent-700)')
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
expect(screen.getByText("Uncheck anything you already have — it won't be counted.")).toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows one surface card per dinner with its Bagel name and day', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
|
||||
await renderPage()
|
||||
|
||||
const tacos = await screen.findByRole('region', { name: 'Beef tacos' })
|
||||
expect(screen.getAllByRole('region')).toHaveLength(2)
|
||||
expect(declaredStyle(tacos).borderRadius).toBe('24px')
|
||||
expect(declaredStyle(tacos).background).toBe('var(--color-surface)')
|
||||
const name = within(tacos).getByRole('heading', { level: 2, name: 'Beef tacos' })
|
||||
expect(declaredStyle(name).fontFamily).toBe('var(--font-heading)')
|
||||
expect(declaredStyle(name).fontSize).toBe('16px')
|
||||
expect(declaredStyle(within(tacos).getByText('MON')).fontSize).toBe('11px')
|
||||
expect(within(screen.getByRole('region', { name: 'Crock pot tikka masala' })).getByText('WED')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('checks the items to buy; one the household has is unchecked, struck through and "have it"', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
|
||||
expect(within(row('Ground beef')).getByText('1 lb')).toBeInTheDocument()
|
||||
expect(declaredStyle(screen.getByText('Ground beef')).textDecoration).toBe('')
|
||||
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(within(row('Taco seasoning')).queryByText('1 packet')).not.toBeInTheDocument()
|
||||
expect(declaredStyle(screen.getByText('Taco seasoning')).textDecoration).toBe('line-through')
|
||||
expect(screen.getAllByText('have it')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('toggles an item at once, then shows the counts the server answers with', async () => {
|
||||
const answer = deferred()
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
|
||||
[`PATCH ${ITEM('i3')}`]: () => answer.response,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
// At once, before the server answers: checked, no longer "have it", and counted.
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(within(row('Taco seasoning')).getByText('1 packet')).toBeInTheDocument()
|
||||
expect(screen.getByText('5 / 6')).toBeInTheDocument()
|
||||
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`]))
|
||||
const call = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')!
|
||||
expect(new Headers(call[1]?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
|
||||
|
||||
// The server's counts are the ones shown.
|
||||
answer.send(jsonResponse(200, { id: 'i3', needed: true, needed_count: 5, total_count: 7 }))
|
||||
expect(await screen.findByText('5 / 7')).toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
})
|
||||
|
||||
it('marks an item "have it" by unchecking it', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
|
||||
[`PATCH ${ITEM('i1')}`]: () => jsonResponse(200, { id: 'i1', needed: false, needed_count: 3, total_count: 6 }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Ground beef' }))
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
expect(within(row('Ground beef')).getByText('have it')).toBeInTheDocument()
|
||||
expect(screen.getByText('3 / 6')).toBeInTheDocument()
|
||||
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i1')} {"needed":false}`]))
|
||||
})
|
||||
|
||||
it('puts a refused toggle back, item and count, and says why with Retry', async () => {
|
||||
const answer = deferred()
|
||||
let patches = 0
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
|
||||
[`PATCH ${ITEM('i3')}`]: () => {
|
||||
patches++
|
||||
return patches === 1 ? answer.response : jsonResponse(200, { id: 'i3', needed: true, needed_count: 5, total_count: 6 })
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(screen.getByText('5 / 6')).toBeInTheDocument()
|
||||
|
||||
answer.send(errorResponse(500, 'internal'))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent('Couldn\'t mark Taco seasoning to buy. internal message')
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('5 / 6')).toBeInTheDocument()
|
||||
expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`, `PATCH ${ITEM('i3')} {"needed":true}`])
|
||||
})
|
||||
|
||||
it('sends toggles one at a time and keeps the count true when one between them fails', async () => {
|
||||
const first = deferred()
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
|
||||
[`PATCH ${ITEM('i3')}`]: () => first.response,
|
||||
[`PATCH ${ITEM('i6')}`]: () => jsonResponse(200, { id: 'i6', needed: true, needed_count: 5, total_count: 6 }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
fireEvent.click(checkbox('Basmati rice'))
|
||||
expect(screen.getByText('6 / 6')).toBeInTheDocument()
|
||||
// The second waits for the first.
|
||||
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`]))
|
||||
|
||||
first.send(errorResponse(500, 'internal'))
|
||||
await waitFor(() => expect(writes(fetchMock)).toHaveLength(2))
|
||||
await waitFor(() => expect(screen.getByText('5 / 6')).toBeInTheDocument())
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
expect(checkbox('Basmati rice')).toBeChecked()
|
||||
})
|
||||
|
||||
it('keeps a refused toggle shown when a later toggle of another item succeeds, until it is retried', async () => {
|
||||
let patches = 0
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
|
||||
[`PATCH ${ITEM('i3')}`]: () => {
|
||||
patches++
|
||||
return patches === 1 ? errorResponse(500, 'internal') : jsonResponse(200, { id: 'i3', needed: true, needed_count: 6, total_count: 6 })
|
||||
},
|
||||
[`PATCH ${ITEM('i6')}`]: () => jsonResponse(200, { id: 'i6', needed: true, needed_count: 5, total_count: 6 }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
|
||||
fireEvent.click(checkbox('Basmati rice'))
|
||||
await waitFor(() => expect(screen.getByText('5 / 6')).toBeInTheDocument())
|
||||
expect(checkbox('Basmati rice')).toBeChecked()
|
||||
// The refusal is still shown: nothing has superseded it.
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.getByText('6 / 6')).toBeInTheDocument())
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
})
|
||||
|
||||
it('reads the list again when a toggle is refused because the week was archived, and shows it read-only', async () => {
|
||||
let reads = 0
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => {
|
||||
reads++
|
||||
return jsonResponse(200, supplyRun(reads === 1 ? {} : { status: 'archived' }))
|
||||
},
|
||||
[`PATCH ${ITEM('i3')}`]: () => errorResponse(409, 'archived'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(reads).toBe(2)
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
|
||||
// It says why, with nothing to retry.
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
|
||||
expect(within(alert).queryByRole('button')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Taco seasoning'))
|
||||
expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`])
|
||||
})
|
||||
|
||||
it('shows the status line once the list was sent to Google Chat', async () => {
|
||||
// Saturday Oct 10, 8:00 am in New York.
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })) })
|
||||
await renderPage()
|
||||
|
||||
const status = await screen.findByText('Sent to Google Chat · Sat 8:00 am')
|
||||
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
|
||||
})
|
||||
|
||||
it('has no status line until the list is sent', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('4 / 6')
|
||||
expect(screen.queryByText(/Sent to Google Chat/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stacks the cards on mobile, with a link to the Supply archive and no header actions', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('4 / 6')
|
||||
const groups = screen.getByTestId('supply-run-groups')
|
||||
expect(declaredStyle(groups).flexDirection).toBe('column')
|
||||
expect(declaredStyle(groups).columnCount).toBe('')
|
||||
expect(screen.getByRole('link', { name: 'Supply archive' })).toHaveAttribute('href', '/plan/shopping/archive')
|
||||
expect(screen.queryByRole('button', { name: 'Send to Google Chat' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Archive' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['tablet (mock 2b)', 1180],
|
||||
['desktop (mock 2f)', 1440],
|
||||
])('lays the cards out in 3 masonry columns on %s, with Archive and Send to Google Chat', async (_layout, width) => {
|
||||
mockViewport(width)
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })) })
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('4 / 6')
|
||||
const groups = screen.getByTestId('supply-run-groups')
|
||||
expect(declaredStyle(groups).columnCount).toBe('3')
|
||||
expect(declaredStyle(screen.getByRole('region', { name: 'Beef tacos' })).breakInside).toBe('avoid')
|
||||
|
||||
const archive = screen.getByRole('link', { name: 'Archive' })
|
||||
expect(archive).toHaveAttribute('href', '/plan/shopping/archive')
|
||||
expect(archive).toHaveClass(buttonStyles.outline!)
|
||||
|
||||
// Send to Google Chat waits for Feature 19: primary, unavailable, and
|
||||
// its tooltip says so. Pressing it sends nothing.
|
||||
const send = screen.getByRole('button', { name: 'Send to Google Chat' })
|
||||
expect(send).toHaveClass(buttonStyles.primary!)
|
||||
expect(send).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(send).toHaveAccessibleDescription('Sending the shopping list to Google Chat arrives in a later update.')
|
||||
fireEvent.click(send)
|
||||
expect(writes(fetchMock)).toEqual([])
|
||||
|
||||
expect(screen.getByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Supply archive' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an archived list read-only, tagged Archived', async () => {
|
||||
const fetchMock = stubServer({
|
||||
'GET /api/v1/shopping/2026-09-28': () =>
|
||||
jsonResponse(200, supplyRun({ status: 'archived' }, '2026-09-28', 'Week 40 · Sep 28 – Oct 4')),
|
||||
})
|
||||
await renderPage('/plan/shopping?week=2026-09-28')
|
||||
|
||||
expect(await screen.findByText('Supply run · Sep 28 – Oct 4')).toBeInTheDocument()
|
||||
expect(screen.getByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
// The marks still show what was bought and what was on hand.
|
||||
expect(within(row('Ground beef')).getByText('1 lb')).toBeInTheDocument()
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(declaredStyle(screen.getByText('Taco seasoning')).textDecoration).toBe('line-through')
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Taco seasoning'))
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
expect(writes(fetchMock)).toEqual([])
|
||||
})
|
||||
|
||||
it('offers no Send to Google Chat for an archived list on tablet', async () => {
|
||||
mockViewport(1180)
|
||||
stubServer({
|
||||
'GET /api/v1/shopping/2026-09-28': () =>
|
||||
jsonResponse(200, supplyRun({ status: 'archived' }, '2026-09-28', 'Week 40 · Sep 28 – Oct 4')),
|
||||
})
|
||||
await renderPage('/plan/shopping?week=2026-09-28')
|
||||
|
||||
await screen.findByText('Archived')
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
expect(screen.queryByRole('button', { name: 'Send to Google Chat' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Archive' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refuses a week that is not a Monday', async () => {
|
||||
const fetchMock = stubServer({})
|
||||
await renderPage('/plan/shopping?week=2026-09-29')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-09-29” is not the Monday of a week, as YYYY-MM-DD.')
|
||||
expect(fetchMock.mock.calls.filter(([input]) => String(input).startsWith('/api/v1/shopping'))).toEqual([])
|
||||
})
|
||||
|
||||
it('shows skeleton cards while the list loads', async () => {
|
||||
const answer = deferred()
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => answer.response })
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByRole('region', { name: 'Shopping list loading' })).toHaveAttribute('aria-busy', 'true')
|
||||
await waitFor(() => expect(screen.getAllByTestId('skeleton-group')).toHaveLength(3))
|
||||
answer.send(jsonResponse(200, supplyRun()))
|
||||
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-group')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says when the list cannot load, and Retry reads it again', async () => {
|
||||
let reads = 0
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => {
|
||||
reads++
|
||||
return reads === 1 ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun())
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't load the shopping list. internal message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers to make the list when the week has none, and shows the list made', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`POST ${GENERATE}`]: () => jsonResponse(200, { ...supplyRun(), skipped: [] }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
|
||||
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
|
||||
})
|
||||
|
||||
it('says why the list could not be made', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`POST ${GENERATE}`]: () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list. not_found message")
|
||||
expect(screen.getByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sentAt', () => {
|
||||
it('says the day and time in the household zone', () => {
|
||||
expect(sentAt(new Date('2026-10-10T12:00:00Z'), 'America/New_York')).toBe('Sat 8:00 am')
|
||||
expect(sentAt(new Date('2026-10-11T03:30:00Z'), 'America/New_York')).toBe('Sat 11:30 pm')
|
||||
})
|
||||
})
|
||||
@@ -1,277 +0,0 @@
|
||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { Unavailable } from '../plan/PlanPage.tsx'
|
||||
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { Checkbox } from '../ui/Checkbox.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import {
|
||||
useGenerateSupplyRun,
|
||||
useSupplyRun,
|
||||
useToggles,
|
||||
WEEK_PARAM,
|
||||
type SupplyRunGroup,
|
||||
type SupplyRunItem,
|
||||
type Toggles,
|
||||
} from './api.ts'
|
||||
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
|
||||
import styles from './SupplyRunPage.module.css'
|
||||
|
||||
// SUPPLY_ARCHIVE is the Supply archive screen.
|
||||
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
|
||||
|
||||
// CHAT_HINT is why Send to Google Chat is unavailable.
|
||||
// TODO(Feature 19): send the Supply run to Google Chat once it is built.
|
||||
const CHAT_HINT = 'Sending the shopping list to Google Chat arrives in a later update.'
|
||||
|
||||
// sentAt is when the Supply run was sent to Google Chat as its status line
|
||||
// says it, in the household's zone: "Sat 8:00 am".
|
||||
export function sentAt(at: Date, timezone: string): string {
|
||||
const day = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: timezone }).format(at)
|
||||
return `${day} ${formatClockTime(localNow(timezone, at).time)}`
|
||||
}
|
||||
|
||||
// ItemRow is one ingredient: on a current Supply run a checkbox, checked
|
||||
// (grape) while it is to buy; unchecked, the name is struck through and
|
||||
// "have it" stands in for the quantity. An archived Supply run shows the
|
||||
// same marks, read-only. An item toggled while the server cannot be reached
|
||||
// says it will sync.
|
||||
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
|
||||
const name = (
|
||||
<>
|
||||
<span className={styles.name}>{item.name}</span>
|
||||
{toggles?.willSync.has(item.id) && (
|
||||
<span className={styles.willSync}>
|
||||
<Icon icon={CloudUpload} size="inline" />
|
||||
will sync
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<li className={[styles.item, !item.needed && styles.have].filter(Boolean).join(' ')}>
|
||||
{toggles === null ? (
|
||||
<span className={styles.readOnly}>
|
||||
<span className={styles.box} aria-hidden="true">
|
||||
{item.needed && <Icon icon={Check} size="inline" className={styles.mark} />}
|
||||
</span>
|
||||
{name}
|
||||
</span>
|
||||
) : (
|
||||
<Checkbox
|
||||
className={styles.check}
|
||||
label={name}
|
||||
checked={item.needed}
|
||||
onChange={() => toggles.toggle({ id: item.id, needed: !item.needed })}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.qty}>{item.needed ? item.qty : 'have it'}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// GroupCard is one dinner's items: its name in Bagel over its day.
|
||||
function GroupCard({ group, toggles }: { group: SupplyRunGroup; toggles: Toggles | null }) {
|
||||
return (
|
||||
<section className={styles.group} aria-label={group.dinner.name}>
|
||||
<header className={styles.groupHeader}>
|
||||
<h2 className={styles.dinner}>{group.dinner.name}</h2>
|
||||
<span className={styles.day}>{weekdayKicker(group.date)}</span>
|
||||
</header>
|
||||
<ul className={styles.items}>
|
||||
{group.items.map((item) => (
|
||||
<ItemRow key={item.id} item={item} toggles={toggles} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// SentStatus is the status line once the Supply run was sent to Google Chat.
|
||||
function SentStatus({ at, timezone }: { at: Date; timezone: string }) {
|
||||
return (
|
||||
<p className={styles.sent}>
|
||||
<Icon icon={MessageSquare} size="inline" />
|
||||
Sent to Google Chat · {sentAt(at, timezone)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ArchiveLink opens the Supply archive: an outline action from tablet up, a
|
||||
// grape link on mobile.
|
||||
function ArchiveLink({ layout }: { layout: Breakpoint }) {
|
||||
const className = layout === 'mobile' ? styles.archiveLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
|
||||
return (
|
||||
<Link to={SUPPLY_ARCHIVE} className={className}>
|
||||
<Icon icon={Archive} size="inline" />
|
||||
{layout === 'mobile' ? 'Supply archive' : 'Archive'}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// SupplyRunList is a week's Supply run (mocks 1l, 2b, 2f): the kicker with
|
||||
// the week's dates, the title with the count still to buy of all items, and
|
||||
// one card per dinner, stacked on mobile and in three masonry columns from
|
||||
// tablet up, where Archive and Send to Google Chat are actions in the
|
||||
// header. On a current Supply run each item toggles at once (useToggles); a
|
||||
// toggle refused puts the item back and says why. An archived one is
|
||||
// read-only, and a toggle it refused is not offered again.
|
||||
function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
|
||||
const toggles = useToggles(weekStart)
|
||||
const run = toggles.shown
|
||||
const archived = run.status === 'archived'
|
||||
const wide = layout !== 'mobile'
|
||||
const failed = toggles.failed
|
||||
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
|
||||
|
||||
return (
|
||||
<div className={`${styles.page} ${styles[layout]}`}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.heading}>
|
||||
<p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p>
|
||||
<div className={styles.titleRow}>
|
||||
<h1 className={styles.title}>Shopping list</h1>
|
||||
{archived && <Tag tone="grape">Archived</Tag>}
|
||||
<p className={styles.count}>
|
||||
{run.needed_count} / {run.total_count}
|
||||
</p>
|
||||
</div>
|
||||
<p className={styles.helper}>
|
||||
{archived
|
||||
? 'This list is in the Supply archive, as it was when the week ended.'
|
||||
: "Uncheck anything you already have — it won't be counted."}
|
||||
</p>
|
||||
</div>
|
||||
{wide && (
|
||||
<div className={styles.actions}>
|
||||
<ArchiveLink layout={layout} />
|
||||
{!archived && (
|
||||
<Unavailable hint={CHAT_HINT} below className={styles.action}>
|
||||
<Icon icon={MessageSquare} size="inline" />
|
||||
Send to Google Chat
|
||||
</Unavailable>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{failed !== null && (
|
||||
<ErrorBanner onRetry={archived ? undefined : () => toggles.toggle(failed.toggle)}>
|
||||
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
|
||||
{failed.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{wide && run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
|
||||
{run.groups.length === 0 ? (
|
||||
<EmptyState title="Nothing to buy">None of this week's dinners has a recipe with ingredients yet.</EmptyState>
|
||||
) : (
|
||||
<div className={styles.groups} data-testid="supply-run-groups">
|
||||
{run.groups.map((group) => (
|
||||
<GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={archived ? null : toggles} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!wide && (
|
||||
<footer className={styles.footer}>
|
||||
{run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
|
||||
<ArchiveLink layout={layout} />
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// SkeletonGroups stands in for the dinner cards while the Supply run loads.
|
||||
function SkeletonGroups({ layout }: { layout: Breakpoint }) {
|
||||
return (
|
||||
<section aria-busy="true" aria-label="Shopping list loading" className={`${styles.page} ${styles[layout]}`}>
|
||||
<Skeleton className={styles.skeletonTitle} />
|
||||
<div className={styles.groups}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} data-testid="skeleton-group" className={styles.skeletonGroup} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// WeekSupplyRun is the Supply run of the week starting on weekStart, or,
|
||||
// when the week has none yet, the offer to make it from the week's plan.
|
||||
function WeekSupplyRun({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
|
||||
const run = useSupplyRun(weekStart)
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
|
||||
// Offline, the Supply run saved on this device stays shown; the offline
|
||||
// pill says it is saved data.
|
||||
if (run.isError && !(run.data !== undefined && run.error instanceof TypeError)) {
|
||||
return <ErrorBanner onRetry={() => void run.refetch()}>Couldn't load the shopping list. {run.error.message}</ErrorBanner>
|
||||
}
|
||||
if (run.isPending || generate.isPending) return <SkeletonGroups layout={layout} />
|
||||
if (run.data === null) {
|
||||
return (
|
||||
<div className={`${styles.page} ${styles[layout]}`}>
|
||||
<EmptyState title="No shopping list yet" action={{ label: 'Make the shopping list', onClick: () => generate.mutate() }}>
|
||||
I'll gather the ingredients of this week's planned dinners into a Supply run.
|
||||
</EmptyState>
|
||||
{generate.isError && (
|
||||
<ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} />
|
||||
}
|
||||
|
||||
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
|
||||
// desktop) of the week the Plan opens on — next week once this week's
|
||||
// plan_draft time has come, otherwise this week — or of the week chosen by
|
||||
// ?week=, such as a week in the Supply archive. Offline, where the settings
|
||||
// cannot be read, it chooses the week by the schedule saved on this device.
|
||||
export function SupplyRunPage() {
|
||||
const problem = useSavedProblem()
|
||||
return (
|
||||
<>
|
||||
{problem !== null && (
|
||||
<ErrorBanner>Changes to the shopping list can't be kept on this device while offline. {problem.message}</ErrorBanner>
|
||||
)}
|
||||
<ChosenSupplyRun />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ChosenSupplyRun() {
|
||||
const layout = useBreakpoint()
|
||||
const settings = useHouseholdSettings()
|
||||
const [params] = useSearchParams()
|
||||
const chosen = params.get(WEEK_PARAM)
|
||||
// Read once, not on every render: a failed read reports a problem, which
|
||||
// renders this again.
|
||||
const [saved] = useState(() => keep(savedSchedule))
|
||||
|
||||
if (chosen !== null && !isMonday(chosen)) {
|
||||
return <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
|
||||
}
|
||||
// fetch rejects with a TypeError only when the network fails.
|
||||
const schedule = settings.data ?? (settings.error instanceof TypeError ? saved : null)
|
||||
if (settings.isError && !schedule) {
|
||||
return <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||
}
|
||||
if (!schedule) return <SkeletonGroups layout={layout} />
|
||||
const week = chosen ?? defaultWeek(schedule, new Date())
|
||||
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} />
|
||||
}
|
||||
|
||||
// isMonday says whether date is a real YYYY-MM-DD Monday.
|
||||
function isMonday(date: string): boolean {
|
||||
try {
|
||||
return weekOf(date) === date
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
import {
|
||||
MutationObserver,
|
||||
useMutation,
|
||||
useMutationState,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type MutationObserverOptions,
|
||||
type QueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { useEffect, useReducer, useSyncExternalStore } from 'react'
|
||||
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { meQueryKey } from '../AuthBootstrap.tsx'
|
||||
import { oneOf } from '../dinners/api.ts'
|
||||
import type { NetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts'
|
||||
import {
|
||||
dequeue,
|
||||
enqueue,
|
||||
keep,
|
||||
markWillSync,
|
||||
queued,
|
||||
SAVED_KEY,
|
||||
saveRun,
|
||||
savedRun,
|
||||
saveSchedule,
|
||||
synced,
|
||||
willSync,
|
||||
} from './offline.ts'
|
||||
|
||||
export const SHOPPING_PATH = '/api/v1/shopping'
|
||||
|
||||
// WEEK_PARAM names the search parameter choosing the week the Supply run
|
||||
// screen shows, its Monday as YYYY-MM-DD: /plan/shopping?week=2026-09-28.
|
||||
export const WEEK_PARAM = 'week'
|
||||
|
||||
// supplyRunScreen is the Supply run screen on the week starting on Monday
|
||||
// weekStart.
|
||||
export function supplyRunScreen(weekStart: string): string {
|
||||
return `/plan/shopping?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
|
||||
}
|
||||
|
||||
// supplyRunPath is the Supply run of the week starting on Monday weekStart.
|
||||
export function supplyRunPath(weekStart: string): string {
|
||||
return `${SHOPPING_PATH}/${encodeURIComponent(weekStart)}`
|
||||
}
|
||||
|
||||
// supplyRunKey is the query of the Supply run of the week starting on
|
||||
// weekStart.
|
||||
export function supplyRunKey(weekStart: string) {
|
||||
return ['shopping', weekStart] as const
|
||||
}
|
||||
|
||||
export const SUPPLY_RUN_STATUSES = ['current', 'archived'] as const
|
||||
|
||||
// SupplyRunStatus is current until the week ends and the Supply run is
|
||||
// frozen into the Supply archive: archived, after which nothing on it
|
||||
// changes.
|
||||
export type SupplyRunStatus = (typeof SUPPLY_RUN_STATUSES)[number]
|
||||
|
||||
// SupplyRunItem is one ingredient: qty is null when the recipe gives none,
|
||||
// and needed is false once it is marked "have it".
|
||||
export type SupplyRunItem = { id: string; name: string; qty: string | null; needed: boolean }
|
||||
|
||||
// SupplyRunGroup is the items of one plan day's dinner: date is the plan
|
||||
// day, household-local YYYY-MM-DD.
|
||||
export type SupplyRunGroup = { dinner: { id: string; name: string }; date: string; items: SupplyRunItem[] }
|
||||
|
||||
// Counts is how many of a Supply run's items are still to buy (needed) of
|
||||
// all of them (total).
|
||||
export type Counts = { needed_count: number; total_count: number }
|
||||
|
||||
// SupplyRun is GET /api/v1/shopping/{weekStart}: label is the week label,
|
||||
// "Week 41 · Oct 5 – 11", title the AI's title once written, and
|
||||
// sent_to_chat_at when it was sent to Google Chat, if it was.
|
||||
export type SupplyRun = Counts & {
|
||||
weekStart: string
|
||||
label: string
|
||||
title: string | null
|
||||
status: SupplyRunStatus
|
||||
sent_to_chat_at: Date | null
|
||||
groups: SupplyRunGroup[]
|
||||
}
|
||||
|
||||
function count(where: string, field: string, value: unknown): number {
|
||||
if (!Number.isInteger(value) || (value as number) < 0) throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not a count`)
|
||||
return value as number
|
||||
}
|
||||
|
||||
function parseCounts(where: string, body: Record<string, unknown>): Counts {
|
||||
const counts = { needed_count: count(where, 'needed_count', body.needed_count), total_count: count(where, 'total_count', body.total_count) }
|
||||
if (counts.needed_count > counts.total_count) throw new Error(`${where}: needed_count is more than total_count`)
|
||||
return counts
|
||||
}
|
||||
|
||||
function parseItem(where: string, body: unknown): SupplyRunItem {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.id !== 'string' ||
|
||||
typeof body.name !== 'string' ||
|
||||
(body.qty !== null && typeof body.qty !== 'string') ||
|
||||
typeof body.needed !== 'boolean'
|
||||
) {
|
||||
throw new Error(`${where}: response has an item that is not a Supply run item`)
|
||||
}
|
||||
return { id: body.id, name: body.name, qty: body.qty, needed: body.needed }
|
||||
}
|
||||
|
||||
function parseGroup(where: string, body: unknown): SupplyRunGroup {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
!isRecord(body.dinner) ||
|
||||
typeof body.dinner.id !== 'string' ||
|
||||
typeof body.dinner.name !== 'string' ||
|
||||
typeof body.date !== 'string' ||
|
||||
!Array.isArray(body.items)
|
||||
) {
|
||||
throw new Error(`${where}: response has a group that is not a dinner's items`)
|
||||
}
|
||||
return {
|
||||
dinner: { id: body.dinner.id, name: body.dinner.name },
|
||||
date: body.date,
|
||||
items: body.items.map((item) => parseItem(where, item)),
|
||||
}
|
||||
}
|
||||
|
||||
// sentAt reads sent_to_chat_at, an RFC 3339 instant or null.
|
||||
function sentAt(where: string, value: unknown): Date | null {
|
||||
if (value === null) return null
|
||||
const at = typeof value === 'string' ? new Date(value) : null
|
||||
if (at === null || Number.isNaN(at.getTime())) {
|
||||
throw new Error(`${where}: sent_to_chat_at ${JSON.stringify(value)} is not an instant`)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
export function parseSupplyRun(where: string, weekStart: string, body: unknown): SupplyRun {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
body.weekStart !== weekStart ||
|
||||
typeof body.label !== 'string' ||
|
||||
(body.title !== null && typeof body.title !== 'string') ||
|
||||
!Array.isArray(body.groups)
|
||||
) {
|
||||
throw new Error(`${where}: response is not the Supply run of the week of ${weekStart}`)
|
||||
}
|
||||
return {
|
||||
weekStart,
|
||||
label: body.label,
|
||||
title: body.title,
|
||||
status: oneOf(where, 'status', SUPPLY_RUN_STATUSES, body.status),
|
||||
sent_to_chat_at: sentAt(where, body.sent_to_chat_at),
|
||||
...parseCounts(where, body),
|
||||
groups: body.groups.map((group) => parseGroup(where, group)),
|
||||
}
|
||||
}
|
||||
|
||||
// savedSupplyRun is the week's Supply run saved on this device, or undefined.
|
||||
function savedSupplyRun(weekStart: string): SupplyRun | undefined {
|
||||
const saved = savedRun(weekStart)
|
||||
return saved === undefined ? undefined : parseSupplyRun(`Saved ${SAVED_KEY}`, weekStart, saved)
|
||||
}
|
||||
|
||||
// useSupplyRun is the Supply run of the week starting on Monday weekStart
|
||||
// (GET /api/v1/shopping/{weekStart}), or null when the week has none (404).
|
||||
// It starts from the Supply run saved on this device, if there is one, so
|
||||
// the list opens offline; the saved one is always read again.
|
||||
export function useSupplyRun(weekStart: string) {
|
||||
const { apiRead } = useApi()
|
||||
return useQuery({
|
||||
queryKey: supplyRunKey(weekStart),
|
||||
initialData: () => keep(() => savedSupplyRun(weekStart)),
|
||||
initialDataUpdatedAt: 0,
|
||||
queryFn: async (): Promise<SupplyRun | null> => {
|
||||
const path = supplyRunPath(weekStart)
|
||||
let body: unknown
|
||||
try {
|
||||
body = (await apiRead<unknown>(path)).body
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null
|
||||
throw error
|
||||
}
|
||||
return parseSupplyRun(`GET ${path}`, weekStart, body)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// useGenerateSupplyRun makes the week's Supply run from its plan (POST
|
||||
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
|
||||
// the one shown.
|
||||
export function useGenerateSupplyRun(weekStart: string) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<SupplyRun> => {
|
||||
const path = `${supplyRunPath(weekStart)}/generate`
|
||||
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||
},
|
||||
onSuccess: (run) => queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run),
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle is one item marked to buy (needed) or "have it" (not needed).
|
||||
export type Toggle = { id: string; needed: boolean }
|
||||
|
||||
// KeyedToggle is a toggle as sent: key tells it apart from any other toggle
|
||||
// of the same item, here and in the queue saved on this device.
|
||||
type KeyedToggle = Toggle & { key: string }
|
||||
|
||||
// toggleKey keys the toggles of the week starting on weekStart.
|
||||
function toggleKey(weekStart: string) {
|
||||
return ['shopping', weekStart, 'toggle'] as const
|
||||
}
|
||||
|
||||
// TOGGLE_SCOPE is the one mutation scope of every toggle of every week, made
|
||||
// here or replayed from the queue saved on this device.
|
||||
const TOGGLE_SCOPE = 'shopping toggle'
|
||||
|
||||
// Toggles is the toggles of one week's Supply run: shown, the Supply run
|
||||
// with those still pending laid over it, the items whose toggles will sync
|
||||
// once the server can be reached, the latest failed toggle no later toggle
|
||||
// of its item has superseded, and toggle to make another.
|
||||
export type Toggles = {
|
||||
shown: SupplyRun
|
||||
willSync: ReadonlySet<string>
|
||||
failed: { toggle: Toggle; error: Error } | null
|
||||
toggle: (toggle: Toggle) => void
|
||||
}
|
||||
|
||||
// TOGGLE_DEADLINE is how long, in milliseconds, one try of a toggle — its
|
||||
// GET /api/v1/me, its PATCH and the /me refresh a csrf 403 to it makes — may
|
||||
// take before it counts as the server not answering. Only toggles have a
|
||||
// deadline: the API client sets none.
|
||||
export const TOGGLE_DEADLINE = 10_000
|
||||
|
||||
// RETRY_FIRST and RETRY_CEILING bound, in milliseconds, how long a toggle
|
||||
// waits while the server cannot be reached before it is tried again anyway:
|
||||
// the first wait, doubled after each failed try up to the ceiling.
|
||||
export const RETRY_FIRST = 1_000
|
||||
export const RETRY_CEILING = 30_000
|
||||
|
||||
// reachable resolves once network says the server can be reached — at once
|
||||
// when it says so now — or after delay milliseconds, whichever is first. The
|
||||
// server coming back says nothing by itself, and the browser sends no online
|
||||
// event when it never thought it was offline, so the toggle is tried again
|
||||
// after delay whatever network says.
|
||||
function reachable(network: NetworkStatus, delay: number): Promise<void> {
|
||||
if (!network.offline()) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
const done = () => {
|
||||
clearTimeout(timer)
|
||||
stop()
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(done, delay)
|
||||
const stop = network.subscribe(() => {
|
||||
if (!network.offline()) done()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// toggleOptions is the mutation every toggle of the week's Supply run is,
|
||||
// made on the Supply run screen (useToggles) or replayed from the queue saved
|
||||
// on this device (useSupplyRunSync).
|
||||
//
|
||||
// Toggles are sent one at a time, in the order made, whatever their week
|
||||
// (one mutation scope, TOGGLE_SCOPE), so the counts each is answered with
|
||||
// include every toggle before it and none after, and a replayed toggle never
|
||||
// overlaps another replayed or made here. While the server cannot be
|
||||
// reached — the device is offline, or the toggle's PATCH failed with a
|
||||
// network error (fetch rejected with a TypeError), or a try ran past its
|
||||
// deadline (TOGGLE_DEADLINE) — a toggle waits, pending, and is tried again
|
||||
// once network says the server can be reached, or after a wait that doubles
|
||||
// from RETRY_FIRST up to RETRY_CEILING, whichever is first: those after it
|
||||
// wait behind it, in order. A try past its deadline is aborted and marks the
|
||||
// network failed, as a network error does, so the item says it will sync; the
|
||||
// server may have applied the aborted PATCH, and sending it again is safe for
|
||||
// the reason a replay is (see useSupplyRunSync). Any answer from the server
|
||||
// settles it: a refusal (a 400, 404, 409 or any other error response) or a
|
||||
// response that is not the item as toggled fails it, and it is not sent again. Before
|
||||
// a toggle is sent, GET /api/v1/me has answered, so it carries the session's
|
||||
// CSRF token even when the app opened offline.
|
||||
//
|
||||
// Every toggle is in the queue saved on this device (enqueue, by useToggles)
|
||||
// until it settles, so a reload replays those the server never answered.
|
||||
function toggleOptions(
|
||||
weekStart: string,
|
||||
api: ApiClient,
|
||||
queryClient: QueryClient,
|
||||
): MutationObserverOptions<Counts, Error, KeyedToggle> {
|
||||
const key = supplyRunKey(weekStart)
|
||||
const mutationKey = toggleKey(weekStart)
|
||||
return {
|
||||
mutationKey,
|
||||
scope: { id: TOGGLE_SCOPE },
|
||||
networkMode: 'always',
|
||||
retry: false,
|
||||
gcTime: Infinity,
|
||||
mutationFn: async ({ key, id, needed }: KeyedToggle): Promise<Counts> => {
|
||||
const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}`
|
||||
const where = `PATCH ${path}`
|
||||
let body: unknown
|
||||
for (let failures = 0; ; failures++) {
|
||||
if (api.network.offline()) markWillSync(key)
|
||||
await reachable(api.network, Math.min(RETRY_FIRST * 2 ** failures, RETRY_CEILING))
|
||||
const deadline = new AbortController()
|
||||
const timer = setTimeout(() => deadline.abort(new DOMException(`${where}: no answer in time`, 'TimeoutError')), TOGGLE_DEADLINE)
|
||||
try {
|
||||
// Each try loads /me itself, under its deadline, rather than share
|
||||
// a /me already in flight that may never answer.
|
||||
if (queryClient.getQueryData(meQueryKey) === undefined) {
|
||||
queryClient.setQueryData(meQueryKey, await api.loadMe(deadline.signal))
|
||||
}
|
||||
body = await api.apiFetch<unknown>(path, { method: 'PATCH', body: { needed }, signal: deadline.signal })
|
||||
break
|
||||
} catch (error) {
|
||||
if (deadline.signal.aborted) {
|
||||
api.network.requestFailed()
|
||||
continue
|
||||
}
|
||||
// The API client marks the network offline as fetch rejects; a
|
||||
// TypeError it did not mark is not a network failure.
|
||||
if (!(error instanceof TypeError) || !api.network.offline()) throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
if (!isRecord(body) || body.id !== id || body.needed !== needed) throw new Error(`${where}: response is not item ${id} as toggled`)
|
||||
return parseCounts(where, body)
|
||||
},
|
||||
// A read of the Supply run sent before the toggle must not land after
|
||||
// its answer, over it.
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: key }),
|
||||
onSuccess: (counts, { id, needed }) => {
|
||||
queryClient.setQueryData<SupplyRun | null>(
|
||||
key,
|
||||
(run) =>
|
||||
run && {
|
||||
...run,
|
||||
...counts,
|
||||
groups: run.groups.map((group) => ({
|
||||
...group,
|
||||
items: group.items.map((item) => (item.id === id ? { ...item, needed } : item)),
|
||||
})),
|
||||
},
|
||||
)
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError && error.code === 'archived') void queryClient.invalidateQueries({ queryKey: key })
|
||||
},
|
||||
// This toggle is still pending as it settles, and those after it wait
|
||||
// for it: the settled toggles of its item are the earlier ones. Settled,
|
||||
// it leaves the saved queue.
|
||||
onSettled: (_counts, _error, { key: settled, id }) => {
|
||||
const cache = queryClient.getMutationCache()
|
||||
for (const earlier of cache.findAll({ mutationKey, predicate: (m) => m.state.status !== 'pending' })) {
|
||||
if ((earlier.state.variables as KeyedToggle).id === id) cache.remove(earlier)
|
||||
}
|
||||
keep(() => dequeue(settled))
|
||||
synced(settled)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// useToggles marks items of the week's Supply run, already read, to buy or
|
||||
// "have it" (PATCH /api/v1/shopping/items/{id}; see toggleOptions). The
|
||||
// Supply run cached is the server's: a toggle's answer writes the item and
|
||||
// the counts it answers with there. Until then the toggle is pending and is
|
||||
// laid over the Supply run shown, so the item and the needed count change at
|
||||
// once; a refused toggle just stops being pending, which puts both back.
|
||||
// An item whose pending toggle was made, or waited, while the server could
|
||||
// not be reached will sync: it says so until that toggle settles, even once
|
||||
// the server can be reached again, and even when the screen is left and
|
||||
// opened again meanwhile (markWillSync keeps it with the page, not here).
|
||||
//
|
||||
// The Supply run and the toggles are read straight from the query and
|
||||
// mutation caches as each render begins, never from the caches' batched
|
||||
// notices, which can arrive apart: a toggle is in the mutation cache as
|
||||
// pending as soon as it is made, and its answer is in the query cache
|
||||
// before it stops being pending. Making a toggle renders again at once, so
|
||||
// the checkbox clicked never shows its old state; useSupplyRun and
|
||||
// useMutationState render again on every later change.
|
||||
//
|
||||
// A failed toggle stays failed, and shown, until a later toggle of its item
|
||||
// supersedes it, such as its Retry: a toggle of another item succeeding
|
||||
// does not hide it. So that a failure is never collected unseen, toggles are
|
||||
// kept (gcTime Infinity), and a settled toggle removes the earlier toggles
|
||||
// of its item it supersedes. A toggle refused because the week was frozen
|
||||
// into the Supply archive (409 archived), even one replayed after the device
|
||||
// was offline, reads the Supply run again, which then renders read-only.
|
||||
export function useToggles(weekStart: string): Toggles {
|
||||
const api = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const key = supplyRunKey(weekStart)
|
||||
const mutationKey = toggleKey(weekStart)
|
||||
useSupplyRun(weekStart)
|
||||
useMutationState({ filters: { mutationKey } })
|
||||
const offline = useSyncExternalStore(api.network.subscribe, api.network.offline)
|
||||
const [, renderAgain] = useReducer((n: number) => n + 1, 0)
|
||||
const run = queryClient.getQueryData<SupplyRun | null>(key)
|
||||
if (!run) throw new Error(`The week of ${weekStart} has no Supply run read to toggle items of.`)
|
||||
const toggles = queryClient
|
||||
.getMutationCache()
|
||||
.findAll({ mutationKey })
|
||||
.map((mutation) => ({ status: mutation.state.status, toggle: mutation.state.variables as KeyedToggle, error: mutation.state.error }))
|
||||
const pending = toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)
|
||||
// Every change of the network status renders again, so each pending
|
||||
// toggle seen while the server cannot be reached is marked.
|
||||
if (offline) pending.forEach((t) => markWillSync(t.key))
|
||||
const failed = toggles
|
||||
.filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id))
|
||||
.at(-1)
|
||||
const { mutate } = useMutation(toggleOptions(weekStart, api, queryClient))
|
||||
return {
|
||||
shown: shown(run, pending),
|
||||
willSync: new Set(pending.filter((t) => willSync(t.key)).map((t) => t.id)),
|
||||
failed: failed === undefined ? null : { toggle: { id: failed.toggle.id, needed: failed.toggle.needed }, error: failed.error! },
|
||||
toggle: ({ id, needed }) => {
|
||||
const queuedToggle = { key: crypto.randomUUID(), weekStart, id, needed }
|
||||
keep(() => enqueue(queuedToggle))
|
||||
mutate({ key: queuedToggle.key, id, needed })
|
||||
renderAgain()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// useSupplyRunSync keeps, on this device, what the Supply run screen needs
|
||||
// to open offline — the household's schedule and each Supply run the server
|
||||
// sends (saveRun) — and replays, in the order made, the toggles saved in the
|
||||
// queue that are not already pending here: those of a page reloaded before
|
||||
// the server answered them.
|
||||
//
|
||||
// Replay is at-least-once, not exactly-once: a toggle leaves the saved queue
|
||||
// only once its answer settles here, so one the server applied just before a
|
||||
// reload is sent again. That duplicate is safe because a toggle sets needed
|
||||
// to a fixed value — the same PATCH applied twice leaves the item and the
|
||||
// counts as applying it once does — so no key is sent for the server to
|
||||
// deduplicate by.
|
||||
//
|
||||
// A replayed toggle's answer is written over the Supply run saved, so the
|
||||
// week's Supply run is cached from it first; it is still read again
|
||||
// (updatedAt 0) when the Supply run screen opens.
|
||||
export function useSupplyRunSync() {
|
||||
const api = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
useEffect(() => {
|
||||
const stop = queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.type !== 'updated' || event.action.type !== 'success') return
|
||||
const [scope, week] = event.query.queryKey
|
||||
const data: unknown = event.query.state.data
|
||||
if (scope === 'shopping' && typeof week === 'string' && event.query.queryKey.length === 2) {
|
||||
keep(() => saveRun(week, data as SupplyRun | null))
|
||||
} else if (scope === HOUSEHOLD_SETTINGS_KEY[0] && week === HOUSEHOLD_SETTINGS_KEY[1]) {
|
||||
keep(() => saveSchedule(data as HouseholdSettings))
|
||||
}
|
||||
})
|
||||
const pending = new Set(
|
||||
queryClient
|
||||
.getMutationCache()
|
||||
.findAll({ mutationKey: ['shopping'], predicate: (m) => m.state.status === 'pending' })
|
||||
.map((m) => (m.state.variables as KeyedToggle).key),
|
||||
)
|
||||
for (const { key, weekStart, id, needed } of keep(queued) ?? []) {
|
||||
if (pending.has(key)) continue
|
||||
const runKey = supplyRunKey(weekStart)
|
||||
if (queryClient.getQueryData(runKey) === undefined) {
|
||||
const saved = keep(() => savedSupplyRun(weekStart))
|
||||
if (saved !== undefined) queryClient.setQueryData(runKey, saved, { updatedAt: 0 })
|
||||
}
|
||||
// A refusal is the mutation's state, which the Supply run screen shows.
|
||||
new MutationObserver(queryClient, toggleOptions(weekStart, api, queryClient)).mutate({ key, id, needed }).catch(() => {})
|
||||
}
|
||||
return stop
|
||||
}, [api, queryClient])
|
||||
}
|
||||
|
||||
// SupplyRunSync runs useSupplyRunSync for the whole app.
|
||||
export function SupplyRunSync(): null {
|
||||
useSupplyRunSync()
|
||||
return null
|
||||
}
|
||||
|
||||
// shown is run as the household sees it with the pending toggles laid over
|
||||
// it: each item as its latest pending toggle marks it, and the needed count
|
||||
// moved to match.
|
||||
function shown(run: SupplyRun, pending: Toggle[]): SupplyRun {
|
||||
const marked = new Map(pending.map((toggle) => [toggle.id, toggle.needed]))
|
||||
let needed = run.needed_count
|
||||
const groups = run.groups.map((group) => ({
|
||||
...group,
|
||||
items: group.items.map((item) => {
|
||||
const mark = marked.get(item.id)
|
||||
if (mark === undefined || mark === item.needed) return item
|
||||
needed += mark ? 1 : -1
|
||||
return { ...item, needed: mark }
|
||||
}),
|
||||
}))
|
||||
return { ...run, needed_count: needed, groups }
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
|
||||
// SAVED_KEY is the localStorage key holding, on this device, what the Supply
|
||||
// run screen needs to open offline — the household's schedule and the
|
||||
// Supply runs last read — and the toggles made that the server has not
|
||||
// answered yet, in the order they were made.
|
||||
export const SAVED_KEY = 'mealquest.supply-run'
|
||||
|
||||
// SAVED_WEEKS is how many weeks' Supply runs are kept: this week's and next
|
||||
// week's are the only ones still current.
|
||||
const SAVED_WEEKS = 2
|
||||
|
||||
// Schedule is the part of the household settings the Supply run screen
|
||||
// needs to choose the week and say times. The service worker never caches
|
||||
// the settings, so it is kept here for the screen to open offline.
|
||||
export type Schedule = { timezone: string; plan_draft_dow: number; plan_draft_time: string }
|
||||
|
||||
// QueuedToggle is a toggle the server has not answered yet: key tells it
|
||||
// apart from any other toggle of the same item.
|
||||
export type QueuedToggle = { key: string; weekStart: string; id: string; needed: boolean }
|
||||
|
||||
// Saved is the stored value: runs holds each week's Supply run as the server
|
||||
// last sent it, by its Monday.
|
||||
type Saved = { schedule: Schedule | null; runs: Record<string, unknown>; queue: QueuedToggle[] }
|
||||
|
||||
function isSchedule(value: unknown): value is Schedule {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.timezone === 'string' &&
|
||||
Number.isInteger(value.plan_draft_dow) &&
|
||||
typeof value.plan_draft_time === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isQueuedToggle(value: unknown): value is QueuedToggle {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.key === 'string' &&
|
||||
typeof value.weekStart === 'string' &&
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.needed === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
// read is the stored value, empty when nothing is stored. A value that is not
|
||||
// one MealQuest stored is an error.
|
||||
function read(): Saved {
|
||||
const text = window.localStorage.getItem(SAVED_KEY)
|
||||
if (text === null) return { schedule: null, runs: {}, queue: [] }
|
||||
const saved: unknown = JSON.parse(text)
|
||||
if (
|
||||
!isRecord(saved) ||
|
||||
(saved.schedule !== null && !isSchedule(saved.schedule)) ||
|
||||
!isRecord(saved.runs) ||
|
||||
!Array.isArray(saved.queue) ||
|
||||
!saved.queue.every(isQueuedToggle)
|
||||
) {
|
||||
throw new Error(`The Supply run saved on this device (${SAVED_KEY}) is not one MealQuest saved.`)
|
||||
}
|
||||
return { schedule: saved.schedule as Schedule | null, runs: saved.runs, queue: saved.queue }
|
||||
}
|
||||
|
||||
function write(change: (saved: Saved) => Saved) {
|
||||
window.localStorage.setItem(SAVED_KEY, JSON.stringify(change(read())))
|
||||
}
|
||||
|
||||
// savedSchedule is the household's schedule as last read, or null.
|
||||
export function savedSchedule(): Schedule | null {
|
||||
return read().schedule
|
||||
}
|
||||
|
||||
export function saveSchedule({ timezone, plan_draft_dow, plan_draft_time }: Schedule) {
|
||||
write((saved) => ({ ...saved, schedule: { timezone, plan_draft_dow, plan_draft_time } }))
|
||||
}
|
||||
|
||||
// savedRun is the week's Supply run as the server last sent it, unparsed, or
|
||||
// undefined when none is saved.
|
||||
export function savedRun(weekStart: string): unknown {
|
||||
return read().runs[weekStart]
|
||||
}
|
||||
|
||||
// saveRun keeps the week's Supply run as the server sent it, with the latest
|
||||
// weeks'. A week with none (null) or one frozen into the Supply archive is
|
||||
// not kept: nothing on it can change.
|
||||
export function saveRun(weekStart: string, run: { status: string } | null) {
|
||||
write((saved) => {
|
||||
const runs = { ...saved.runs }
|
||||
delete runs[weekStart]
|
||||
if (run !== null && run.status !== 'archived') runs[weekStart] = run
|
||||
const latest = Object.keys(runs).sort().slice(-SAVED_WEEKS)
|
||||
return { ...saved, runs: Object.fromEntries(latest.map((week) => [week, runs[week]])) }
|
||||
})
|
||||
}
|
||||
|
||||
// queued is the toggles the server has not answered, in the order made.
|
||||
export function queued(): QueuedToggle[] {
|
||||
return read().queue
|
||||
}
|
||||
|
||||
export function enqueue(toggle: QueuedToggle) {
|
||||
write((saved) => ({ ...saved, queue: [...saved.queue, toggle] }))
|
||||
}
|
||||
|
||||
export function dequeue(key: string) {
|
||||
write((saved) => ({ ...saved, queue: saved.queue.filter((toggle) => toggle.key !== key) }))
|
||||
}
|
||||
|
||||
// The keys of the toggles that will sync: made, or waiting, while the server
|
||||
// could not be reached, and not settled yet. They live here, with the page,
|
||||
// as the toggles in the mutation cache do: the Supply run screen is left and
|
||||
// opened again while a toggle is still pending, and starts with nothing of
|
||||
// its own.
|
||||
const syncing = new Set<string>()
|
||||
|
||||
export function markWillSync(key: string) {
|
||||
syncing.add(key)
|
||||
}
|
||||
|
||||
export function willSync(key: string): boolean {
|
||||
return syncing.has(key)
|
||||
}
|
||||
|
||||
export function synced(key: string) {
|
||||
syncing.delete(key)
|
||||
}
|
||||
|
||||
// The latest failure to read or write the saved Supply run, which the Supply
|
||||
// run screen shows: a toggle that cannot be kept may be lost on a reload.
|
||||
let problem: Error | null = null
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
// keep runs one read or write of the saved Supply run, and returns its
|
||||
// result, or undefined when it failed: the failure is reported to the Supply
|
||||
// run screen. Listeners hear of it after the current render.
|
||||
export function keep<T>(action: () => T): T | undefined {
|
||||
try {
|
||||
return action()
|
||||
} catch (error) {
|
||||
problem = error instanceof Error ? error : new Error(String(error))
|
||||
queueMicrotask(() => listeners.forEach((listener) => listener()))
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
// useSavedProblem is the latest failure to read or write the saved Supply
|
||||
// run, or null.
|
||||
export function useSavedProblem(): Error | null {
|
||||
return useSyncExternalStore(subscribe, () => problem)
|
||||
}
|
||||
@@ -1,602 +0,0 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus, type NetworkWindow } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { RETRY_FIRST, SupplyRunSync, TOGGLE_DEADLINE } from './api.ts'
|
||||
import { SAVED_KEY } from './offline.ts'
|
||||
import { SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
|
||||
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
|
||||
|
||||
// Monday's tacos: two items to buy and one the household has.
|
||||
const ITEMS = [
|
||||
{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true },
|
||||
{ id: 'i2', name: 'Corn tortillas', qty: '12', needed: true },
|
||||
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
|
||||
]
|
||||
|
||||
// Next week's curry: one item to buy.
|
||||
const NEXT_ITEMS = [{ id: 'n1', name: 'Chicken thighs', qty: '2 lb', needed: true }]
|
||||
|
||||
// The items of each week, by its Monday.
|
||||
const WEEKS: Record<string, typeof ITEMS> = { '2026-10-05': ITEMS, '2026-10-12': NEXT_ITEMS }
|
||||
|
||||
function itemsOf(week: string) {
|
||||
const items = WEEKS[week]
|
||||
if (items === undefined) throw new Error(`no week ${week}`)
|
||||
return items
|
||||
}
|
||||
|
||||
// The browser's online flag; goOffline and goOnline change it and send the
|
||||
// page's window the event a browser sends.
|
||||
let onLine: boolean
|
||||
// The server can be reached: false, every request fails as fetch does when
|
||||
// the network fails, whatever onLine says.
|
||||
let reachable: boolean
|
||||
// The window of the page now open: a reload opens a new one, and the old
|
||||
// page hears nothing more.
|
||||
let win: NetworkWindow & EventTarget
|
||||
|
||||
function openWindow() {
|
||||
win = Object.assign(new EventTarget(), {
|
||||
navigator: {
|
||||
get onLine() {
|
||||
return onLine
|
||||
},
|
||||
},
|
||||
}) as unknown as NetworkWindow & EventTarget
|
||||
return win
|
||||
}
|
||||
|
||||
function goOffline() {
|
||||
onLine = false
|
||||
act(() => void win.dispatchEvent(new Event('offline')))
|
||||
}
|
||||
|
||||
function goOnline() {
|
||||
onLine = true
|
||||
reachable = true
|
||||
act(() => void win.dispatchEvent(new Event('online')))
|
||||
}
|
||||
|
||||
// stubServer is the server's Supply runs of this week and next: a PATCH
|
||||
// marks its item and answers with its week's counts, unless refuse answers
|
||||
// it instead; when hold is given, the item is marked at once but the answer
|
||||
// waits for the promise hold returns, or until the request is aborted; holdMe
|
||||
// holds GET /api/v1/me's answer the same way. This week's counts as archived
|
||||
// once archive is called.
|
||||
function stubServer(
|
||||
refuse: (id: string) => Response | undefined = () => undefined,
|
||||
hold?: () => Promise<void>,
|
||||
holdMe?: () => Promise<void>,
|
||||
) {
|
||||
const needed = new Map(Object.values(WEEKS).flatMap((items) => items.map((item) => [item.id, item.needed] as const)))
|
||||
let status = 'current'
|
||||
// How many PATCHes are unanswered now, and the most there have been at once.
|
||||
let inFlight = 0
|
||||
let mostInFlight = 0
|
||||
// How many GET /api/v1/me requests have been answered.
|
||||
let meAnswered = 0
|
||||
const counts = (week: string) => {
|
||||
const marks = itemsOf(week).map((item) => needed.get(item.id)!)
|
||||
return { needed_count: marks.filter(Boolean).length, total_count: marks.length }
|
||||
}
|
||||
const run = (week: string) => ({
|
||||
weekStart: week,
|
||||
label: week === '2026-10-05' ? 'Week 41 · Oct 5 – 11' : 'Week 42 · Oct 12 – 18',
|
||||
title: null,
|
||||
status: week === '2026-10-05' ? status : 'current',
|
||||
sent_to_chat_at: null,
|
||||
...counts(week),
|
||||
groups: [
|
||||
{
|
||||
dinner: { id: week === '2026-10-05' ? 'd0' : 'd1', name: week === '2026-10-05' ? 'Beef tacos' : 'Chicken curry' },
|
||||
date: week,
|
||||
items: itemsOf(week).map((item) => ({ ...item, needed: needed.get(item.id)! })),
|
||||
},
|
||||
],
|
||||
})
|
||||
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
|
||||
if (!onLine || !reachable) throw new TypeError('Failed to fetch')
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
if (key === 'GET /api/v1/me') {
|
||||
await answerAfter(init?.signal, holdMe)
|
||||
meAnswered++
|
||||
return jsonResponse(200, ME)
|
||||
}
|
||||
if (key === 'GET /api/v1/settings/household') return jsonResponse(200, HOUSEHOLD_SETTINGS)
|
||||
if (key === `GET ${THIS_WEEK}`) return jsonResponse(200, run('2026-10-05'))
|
||||
const week = Object.keys(WEEKS).find((w) => itemsOf(w).some((i) => key === `PATCH ${ITEM(i.id)}`))
|
||||
if (week === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
const id = itemsOf(week).find((i) => key === `PATCH ${ITEM(i.id)}`)!.id
|
||||
const refusal = refuse(id)
|
||||
if (refusal !== undefined) return refusal
|
||||
const body = JSON.parse(String(init?.body)) as { needed: boolean }
|
||||
needed.set(id, body.needed)
|
||||
const answer = jsonResponse(200, { id, needed: body.needed, ...counts(week) })
|
||||
inFlight++
|
||||
mostInFlight = Math.max(mostInFlight, inFlight)
|
||||
try {
|
||||
await answerAfter(init?.signal, hold)
|
||||
} finally {
|
||||
inFlight--
|
||||
}
|
||||
return answer
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return {
|
||||
fetchMock,
|
||||
run,
|
||||
mostInFlight: () => mostInFlight,
|
||||
meAnswered: () => meAnswered,
|
||||
archive: () => {
|
||||
status = 'archived'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// answerAfter resolves once the promise hold returns does, at once without
|
||||
// hold, or rejects with signal's reason once it aborts.
|
||||
function answerAfter(signal: AbortSignal | null | undefined, hold?: () => Promise<void>) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(signal.reason as Error))
|
||||
void (hold?.() ?? Promise.resolve()).then(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
// held is a hold for stubServer whose answers wait until release is called,
|
||||
// each one released in turn.
|
||||
function held() {
|
||||
const waiting: (() => void)[] = []
|
||||
return {
|
||||
hold: () => new Promise<void>((resolve) => waiting.push(resolve)),
|
||||
waiting: () => waiting.length,
|
||||
release: () => waiting.shift()!(),
|
||||
}
|
||||
}
|
||||
|
||||
// mes counts the GET /api/v1/me requests sent.
|
||||
function mes(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => String(input) === '/api/v1/me' && (init?.method ?? 'GET') === 'GET').length
|
||||
}
|
||||
|
||||
// patches lists the PATCHes sent, as "path body".
|
||||
function patches(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([input, init]) => `${String(input)} ${String(init?.body)}`)
|
||||
}
|
||||
|
||||
// queue is the toggles saved on this device, as "id needed".
|
||||
function queue(): string[] {
|
||||
const saved = localStorage.getItem(SAVED_KEY)
|
||||
if (saved === null) return []
|
||||
return (JSON.parse(saved) as { queue: { id: string; needed: boolean }[] }).queue.map((t) => `${t.id} ${t.needed}`)
|
||||
}
|
||||
|
||||
// openPage opens the Supply run screen in a new window, as the app does:
|
||||
// with SupplyRunSync, and with /me loaded unless the server cannot be
|
||||
// reached. leave goes to another screen of the same page, and reopen comes
|
||||
// back to the Supply run screen.
|
||||
async function openPage() {
|
||||
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(openWindow()), createVerifiedSession(localStorage))
|
||||
if (onLine && reachable) await api.loadMe()
|
||||
const queryClient = createQueryClient()
|
||||
const app = (onScreen: boolean) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/plan/shopping']}>
|
||||
<SupplyRunSync />
|
||||
{onScreen && <SupplyRunPage />}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const view = render(app(true))
|
||||
return { ...view, leave: () => view.rerender(app(false)), reopen: () => view.rerender(app(true)) }
|
||||
}
|
||||
|
||||
// reload ends the page open and its timers, as a reload does: a toggle it
|
||||
// was waiting to try again is never tried, by it, again.
|
||||
function reload(page: { unmount: () => void }) {
|
||||
page.unmount()
|
||||
vi.clearAllTimers()
|
||||
}
|
||||
|
||||
async function advance(ms: number) {
|
||||
await act(() => vi.advanceTimersByTimeAsync(ms))
|
||||
}
|
||||
|
||||
function checkbox(name: string) {
|
||||
return screen.getByRole('checkbox', { name: new RegExp(`^${name}`) })
|
||||
}
|
||||
|
||||
function row(name: string) {
|
||||
return screen.getByText(name).closest('li')!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onLine = true
|
||||
reachable = true
|
||||
mockViewport(390)
|
||||
// setTimeout moves with real time, and a test can jump past a deadline or
|
||||
// a wait (advance).
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
describe('offline toggle queue', () => {
|
||||
it('applies toggles made offline at once, saves them in order and sends them in that order once online', async () => {
|
||||
const { fetchMock } = stubServer()
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
|
||||
// Applied here, counted, marked to sync and saved in order; nothing sent.
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Corn tortillas')).queryByText('will sync')).not.toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false', 'i3 false'])
|
||||
expect(patches(fetchMock)).toEqual([])
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i1')} {"needed":false}`,
|
||||
`${ITEM('i3')} {"needed":false}`,
|
||||
])
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('queues a toggle whose PATCH fails with a network error and sends it again once back online', async () => {
|
||||
const { fetchMock } = stubServer()
|
||||
await openPage()
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(screen.getByText('3 / 3')).toBeInTheDocument()
|
||||
|
||||
// The browser still says online, but the server cannot be reached.
|
||||
reachable = false
|
||||
fireEvent.click(checkbox('Corn tortillas'))
|
||||
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`])
|
||||
expect(checkbox('Corn tortillas')).not.toBeChecked()
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i2 false'])
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`, `${ITEM('i2')} {"needed":false}`])
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the saved list after a reload offline and replays the saved toggles in order once online', async () => {
|
||||
const { fetchMock } = stubServer()
|
||||
const first = await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// Reload while offline: the settings, /me and the list cannot be read.
|
||||
reload(first)
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(patches(fetchMock)).toEqual([])
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('discards a queued toggle the server refuses because the list was archived, puts the count back and says so', async () => {
|
||||
const server = stubServer(() => errorResponse(409, 'archived'))
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
expect(screen.getByText('3 / 3')).toBeInTheDocument()
|
||||
|
||||
server.archive()
|
||||
goOnline()
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
|
||||
expect(queue()).toEqual([])
|
||||
// Sent once, never again.
|
||||
goOnline()
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`]))
|
||||
})
|
||||
|
||||
it('does not queue a toggle the server refuses: it is put back and says why', async () => {
|
||||
const server = stubServer(() => errorResponse(400, 'invalid'))
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Ground beef “have it”. invalid message")
|
||||
expect(checkbox('Ground beef')).toBeChecked()
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
|
||||
expect(queue()).toEqual([])
|
||||
|
||||
goOffline()
|
||||
goOnline()
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('offline toggle queue, replay', () => {
|
||||
it('replays a queue spanning two weeks strictly in order, one PATCH at a time, a toggle made meanwhile after it', async () => {
|
||||
const answers = held()
|
||||
const server = stubServer(undefined, answers.hold)
|
||||
const queued = [
|
||||
{ key: 'k1', weekStart: '2026-10-05', id: 'i3', needed: true },
|
||||
{ key: 'k2', weekStart: '2026-10-12', id: 'n1', needed: false },
|
||||
{ key: 'k3', weekStart: '2026-10-05', id: 'i1', needed: false },
|
||||
]
|
||||
const runs = { '2026-10-05': server.run('2026-10-05'), '2026-10-12': server.run('2026-10-12') }
|
||||
localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued }))
|
||||
|
||||
await openPage()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' }))
|
||||
for (let sent = 1; sent <= 4; sent++) {
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(sent))
|
||||
// The next one is not sent before this one is answered.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(patches(server.fetchMock)).toHaveLength(sent)
|
||||
act(() => answers.release())
|
||||
}
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('n1')} {"needed":false}`,
|
||||
`${ITEM('i1')} {"needed":false}`,
|
||||
`${ITEM('i2')} {"needed":false}`,
|
||||
])
|
||||
expect(server.mostInFlight()).toBe(1)
|
||||
await waitFor(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('replays at least once: a toggle the server applied before a reload is sent again and leaves the item and count the same', async () => {
|
||||
const answers = held()
|
||||
const server = stubServer(undefined, answers.hold)
|
||||
const first = await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
// The server applied it; the page reloads before its answer arrives.
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
expect(queue()).toEqual(['i3 true'])
|
||||
reload(first)
|
||||
act(() => answers.release())
|
||||
|
||||
await openPage()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
act(() => answers.release())
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`])
|
||||
await waitFor(() => expect(screen.getByText('3 / 3')).toBeInTheDocument())
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(server.run('2026-10-05')).toMatchObject({ needed_count: 3, total_count: 3 })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps "will sync" on a queued toggle until its delayed replay is answered', async () => {
|
||||
const answers = held()
|
||||
stubServer(undefined, answers.hold)
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i1 false'])
|
||||
|
||||
act(() => answers.release())
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(queue()).toEqual([])
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('aborts a PATCH that never answers at its deadline, keeps it pending and tries it again before the toggles after it', async () => {
|
||||
let tries = 0
|
||||
// The first PATCH never answers; every later one answers at once.
|
||||
const server = stubServer(undefined, () => (++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()))
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(1))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// Past the deadline the hung PATCH is aborted; both still wait, in order.
|
||||
await advance(TOGGLE_DEADLINE)
|
||||
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(patches(server.fetchMock)).toHaveLength(1)
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
await advance(RETRY_FIRST)
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i1')} {"needed":false}`,
|
||||
])
|
||||
expect(server.mostInFlight()).toBe(1)
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('abandons a GET /api/v1/me that never answers at its deadline and loads it afresh before the toggles after it', async () => {
|
||||
let tries = 0
|
||||
// The page loaded /me; the first toggle loads it again, and that /me never
|
||||
// answers. Every later /me answers at once.
|
||||
const server = stubServer(undefined, undefined, () => (++tries === 2 ? new Promise<void>(() => {}) : Promise.resolve()))
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
await waitFor(() => expect(tries).toBe(2))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// Past the deadline the hung /me is abandoned; both still wait, in order.
|
||||
await advance(TOGGLE_DEADLINE)
|
||||
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(patches(server.fetchMock)).toEqual([])
|
||||
|
||||
// The next try sends a fresh /me, which the server answers.
|
||||
await advance(RETRY_FIRST)
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(mes(server.fetchMock)).toBe(3)
|
||||
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
|
||||
expect(server.mostInFlight()).toBe(1)
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('abandons the /me refresh of a csrf 403 that never answers at its deadline and refreshes afresh on the next try', async () => {
|
||||
let tries = 0
|
||||
// The session's token changed: each PATCH is refused for it until a /me
|
||||
// after the first refusal has answered. The first such refresh never
|
||||
// answers.
|
||||
let refusedAt: number | null = null
|
||||
const server = stubServer(
|
||||
() => {
|
||||
if (refusedAt !== null && server.meAnswered() > refusedAt) return undefined
|
||||
refusedAt ??= server.meAnswered()
|
||||
return errorResponse(403, 'csrf')
|
||||
},
|
||||
undefined,
|
||||
() => (refusedAt !== null && ++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()),
|
||||
)
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
await waitFor(() => expect(tries).toBe(1))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(patches(server.fetchMock)).toHaveLength(1)
|
||||
|
||||
// Past the deadline the hung refresh is abandoned; both still wait, in order.
|
||||
await advance(TOGGLE_DEADLINE)
|
||||
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// The next try is refused again and sends a fresh refresh, which answers.
|
||||
await advance(RETRY_FIRST)
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i1')} {"needed":false}`,
|
||||
])
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tries a toggle again once the server is back, with no online event from the browser', async () => {
|
||||
const { fetchMock } = stubServer()
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
// The browser says online throughout; the server cannot be reached, then can.
|
||||
reachable = false
|
||||
fireEvent.click(checkbox('Corn tortillas'))
|
||||
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
|
||||
reachable = true
|
||||
|
||||
await advance(RETRY_FIRST)
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
// The first try failed at GET /api/v1/me, before its PATCH.
|
||||
expect(patches(fetchMock)).toEqual([`${ITEM('i2')} {"needed":false}`])
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps "will sync" when the Supply run screen is left and opened again while the replay is pending', async () => {
|
||||
const answers = held()
|
||||
stubServer(undefined, answers.hold)
|
||||
const page = await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
|
||||
page.leave()
|
||||
goOnline()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
page.reopen()
|
||||
expect(await within(row('Ground beef')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i1 false'])
|
||||
|
||||
act(() => answers.release())
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(queue()).toEqual([])
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says the saved list is unreadable when it is corrupt offline, without rendering again and again', async () => {
|
||||
stubServer()
|
||||
onLine = false
|
||||
localStorage.setItem(SAVED_KEY, '{corrupt')
|
||||
const getItem = vi.spyOn(Storage.prototype, 'getItem')
|
||||
await openPage()
|
||||
|
||||
expect(await screen.findByText(/Couldn't load the household settings/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/can't be kept on this device while offline/)).toBeInTheDocument()
|
||||
const reads = getItem.mock.calls.filter(([key]) => key === SAVED_KEY).length
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user