Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b0a63e1b5 | ||
|
|
2feaced7c9 | ||
|
|
a72ef1cbdd | ||
|
|
791b321b71 | ||
|
|
be41b56690 | ||
|
|
3a39fc8f2c | ||
|
|
d8633d56a4 | ||
|
|
9046b6df78 |
@@ -82,7 +82,8 @@ sqlc:
|
|||||||
$(SQLC) generate
|
$(SQLC) generate
|
||||||
|
|
||||||
# Fails on any difference from the committed generated code, including new
|
# Fails on any difference from the committed generated code, including new
|
||||||
# or deleted files.
|
# or deleted files. A failing git status is itself a failure: its empty
|
||||||
|
# output would otherwise read as "no drift".
|
||||||
sqlc-check: sqlc
|
sqlc-check: sqlc
|
||||||
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \
|
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))" || { echo "git status failed; cannot check internal/db for drift"; exit 1; }; \
|
||||||
if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi
|
if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi
|
||||||
|
|||||||
+17
-6
@@ -105,7 +105,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
|||||||
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
|
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
|
||||||
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
|
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
|
||||||
| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. |
|
| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. |
|
||||||
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
|
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date), frozen_dinner_name (the dinner's name when its list was frozen into the Supply archive, kept whatever it is later renamed; NULL while the list is current, which shows its dinners' names now; never empty). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
|
||||||
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
|
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
|
||||||
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
|
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
|
||||||
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
|
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
|
||||||
@@ -635,7 +635,10 @@ automatic** (D-021).
|
|||||||
|
|
||||||
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
|
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
|
||||||
archive: `status` becomes `archived` and `frozen_at` is set, after which
|
archive: `status` becomes `archived` and `frozen_at` is set, after which
|
||||||
generating the list or changing an item is 409 `archived`. It freezes only
|
generating the list or changing an item is 409 `archived`. In the same
|
||||||
|
transaction each group keeps its dinner's name as `frozen_dinner_name`, so
|
||||||
|
the frozen list and its post show the names they had when frozen, whatever
|
||||||
|
a dinner is renamed later. It freezes only
|
||||||
a week that has ended in the household's time zone (its Monday is before
|
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
|
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
|
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
|
||||||
@@ -1120,7 +1123,10 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
|||||||
- The week's Supply run (Feature 17) is made from a pushed plan: after
|
- The week's Supply run (Feature 17) is made from a pushed plan: after
|
||||||
a successful `Push`, the handler runs `shopping.GeneratePushed` for
|
a successful `Push`, the handler runs `shopping.GeneratePushed` for
|
||||||
the week at the plan revision `Push` pushed, and the 200 answer
|
the week at the plan revision `Push` pushed, and the 200 answer
|
||||||
carries `supply_run: {status, error}`. `status` is `generated`;
|
carries `supply_run: {status, error, title_job_id}`. `title_job_id` is
|
||||||
|
the `title_supply_run` job titling a generated list, as for `POST
|
||||||
|
/shopping/{weekStart}/generate`; null when the list already has its
|
||||||
|
title or was not generated. `status` is `generated`;
|
||||||
`archived` when the week's Supply run is in the Supply archive and was
|
`archived` when the week's Supply run is in the Supply archive and was
|
||||||
left as it was; or `failed` — error `plan_changed` (retryable, logged
|
left as it was; or `failed` — error `plan_changed` (retryable, logged
|
||||||
as a warning) when the plan was edited between the push and
|
as a warning) when the plan was edited between the push and
|
||||||
@@ -1143,8 +1149,12 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
|||||||
when the week has no Supply run, 400 `invalid_week`.
|
when the week has no Supply run, 400 `invalid_week`.
|
||||||
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
|
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
|
||||||
through `shopping.Generate` and answers with the same shape plus
|
through `shopping.Generate` and answers with the same shape plus
|
||||||
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
|
`skipped: [{date, dinner_id, dinner_name, reason}]` and
|
||||||
has no plan, 409 `archived` when its list is in the Supply archive.
|
`title_job_id`, the `title_supply_run` job titling the list, which
|
||||||
|
`GET /api/v1/jobs/{id}` reports on (failed `title_failed` when the title
|
||||||
|
could not be written); null when the list already has its title. 404
|
||||||
|
when the week has no plan, 409 `archived` when its list is in the Supply
|
||||||
|
archive.
|
||||||
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
|
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
|
||||||
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
|
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
|
||||||
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
|
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
|
||||||
@@ -1180,7 +1190,8 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
|||||||
Monday is a household-local date, so the week of Monday 28 December
|
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
|
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
|
UI sets it in capitals), `title` null while untitled, `dinners` the
|
||||||
list's dinners in day order and `dinnerCount` their number,
|
list's dinners in day order (as named when the list was frozen; the
|
||||||
|
current week's as named now) and `dinnerCount` their number,
|
||||||
`itemCount` all items, `onHandCount` the items marked "have it", and
|
`itemCount` all items, `onHandCount` the items marked "have it", and
|
||||||
`current` true for the household's current week. 400 `invalid_request`
|
`current` true for the household's current week. 400 `invalid_request`
|
||||||
for any other `by`. A frozen list is read through `GET
|
for any other `by`. A frozen list is read through `GET
|
||||||
|
|||||||
+17
-2
@@ -79,6 +79,7 @@ type Auth struct {
|
|||||||
provider *oidc.Provider
|
provider *oidc.Provider
|
||||||
client *http.Client
|
client *http.Client
|
||||||
cookie loginCookie
|
cookie loginCookie
|
||||||
|
pending *PendingStates
|
||||||
sessions Sessions
|
sessions Sessions
|
||||||
allowed []string
|
allowed []string
|
||||||
// endSessionURL is the provider's end_session_endpoint; logout ends
|
// endSessionURL is the provider's end_session_endpoint; logout ends
|
||||||
@@ -157,6 +158,7 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
|||||||
client: client,
|
client: client,
|
||||||
allowed: slices.Clone(cfg.AllowedGroups),
|
allowed: slices.Clone(cfg.AllowedGroups),
|
||||||
cookie: newLoginCookie(cfg.SecretKey),
|
cookie: newLoginCookie(cfg.SecretKey),
|
||||||
|
pending: NewPendingStates(loginCookieTTL),
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
endSessionURL: discovered.EndSessionURI,
|
endSessionURL: discovered.EndSessionURI,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
@@ -250,7 +252,13 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
Verifier: oauth2.GenerateVerifier(),
|
Verifier: oauth2.GenerateVerifier(),
|
||||||
Return: returnTo,
|
Return: returnTo,
|
||||||
}
|
}
|
||||||
if err := a.cookie.set(w, state, time.Now()); err != nil {
|
now := time.Now()
|
||||||
|
if err := a.pending.Add(state.State, now); err != nil {
|
||||||
|
a.logger.WarnContext(r.Context(), "login refused", "error", err)
|
||||||
|
http.Error(w, "too many sign-ins in progress; try again later", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.cookie.set(w, state, now); err != nil {
|
||||||
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
|
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -262,7 +270,7 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// callback completes the flow: it checks state against the login cookie,
|
// callback completes the flow: it checks state against the login cookie,
|
||||||
// exchanges the code, verifies the ID token and nonce, then starts a session
|
// consumes it, exchanges the code, verifies the ID token and nonce, then starts a session
|
||||||
// if the account is in an allowed group. Membership is checked on every
|
// if the account is in an allowed group. Membership is checked on every
|
||||||
// login, so an account removed from the group is refused at its next one.
|
// login, so an account removed from the group is refused at its next one.
|
||||||
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -283,6 +291,13 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
|
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A copy of the cookie verifies until it expires, so the state is
|
||||||
|
// single-use only because it is consumed here.
|
||||||
|
if !a.pending.Consume(state.State) {
|
||||||
|
a.logger.WarnContext(ctx, "login callback state already used")
|
||||||
|
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if q.Get("error") != "" {
|
if q.Get("error") != "" {
|
||||||
// error and error_description are provider-supplied text; only the
|
// error and error_description are provider-supplied text; only the
|
||||||
// fixed category is logged.
|
// fixed category is logged.
|
||||||
|
|||||||
@@ -497,6 +497,59 @@ func TestCallbackDefaultReturnPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A saved copy of the login cookie still verifies until it expires, so a
|
||||||
|
// replay with a second valid code is refused only because the server
|
||||||
|
// consumed the state on first use.
|
||||||
|
func TestCallbackRefusesReplayedLoginState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newHarness(t)
|
||||||
|
l := h.login(t, LoginPath)
|
||||||
|
idToken := h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))
|
||||||
|
|
||||||
|
if resp := h.callback(l, l.query.Get("state"), idToken); resp.StatusCode != http.StatusSeeOther {
|
||||||
|
t.Fatalf("first callback status = %d, want 303", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp := h.callback(l, l.query.Get("state"), idToken)
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("replayed callback status = %d, want 400", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if n := len(h.sessions.started()); n != 1 {
|
||||||
|
t.Errorf("%d sessions started, want 1", n)
|
||||||
|
}
|
||||||
|
assertLoginCookieCleared(t, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending login states live in memory, so a login started before a restart
|
||||||
|
// fails closed after it even though its cookie still verifies.
|
||||||
|
func TestCallbackRefusesLoginStartedBeforeRestart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newHarness(t)
|
||||||
|
l := h.login(t, LoginPath)
|
||||||
|
|
||||||
|
restarted, err := New(context.Background(), Config{
|
||||||
|
Issuer: h.provider.issuer(),
|
||||||
|
ClientID: testClientID,
|
||||||
|
ClientSecret: testClientSecret,
|
||||||
|
PublicURL: testPublicURL,
|
||||||
|
SecretKey: []byte("server-secret-key"),
|
||||||
|
AllowedGroups: testAllowedGroups,
|
||||||
|
}, h.sessions, slog.New(slog.DiscardHandler))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := chi.NewRouter()
|
||||||
|
restarted.Mount(r)
|
||||||
|
h.router = r
|
||||||
|
|
||||||
|
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("callback after restart status = %d, want 400", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if n := len(h.sessions.started()); n != 0 {
|
||||||
|
t.Errorf("%d sessions started, want none", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCallbackRejected(t *testing.T) {
|
func TestCallbackRejected(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -819,6 +872,44 @@ func TestLoginCookieExpires(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPendingStates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
p := NewPendingStates(time.Minute)
|
||||||
|
now := time.Now()
|
||||||
|
if err := p.Add("first", now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !p.Consume("first") {
|
||||||
|
t.Error("Consume of a pending state = false")
|
||||||
|
}
|
||||||
|
if p.Consume("first") {
|
||||||
|
t.Error("second Consume of a state = true")
|
||||||
|
}
|
||||||
|
if p.Consume("never-added") {
|
||||||
|
t.Error("Consume of an unknown state = true")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range maxPendingStates - 1 {
|
||||||
|
if err := p.Add(fmt.Sprint("s", i), now); err != nil {
|
||||||
|
t.Fatalf("Add %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "first" was consumed but has not expired, so it still counts.
|
||||||
|
if err := p.Add("over", now); !errors.Is(err, ErrTooManyPending) {
|
||||||
|
t.Errorf("Add past the bound = %v, want ErrTooManyPending", err)
|
||||||
|
}
|
||||||
|
// Once the earlier states expire, they are forgotten and a new one fits.
|
||||||
|
if err := p.Add("later", now.Add(time.Minute)); err != nil {
|
||||||
|
t.Errorf("Add after expiry: %v", err)
|
||||||
|
}
|
||||||
|
if p.Consume("s0") {
|
||||||
|
t.Error("an expired state was still pending")
|
||||||
|
}
|
||||||
|
if !p.Consume("later") {
|
||||||
|
t.Error("the state added after expiry is not pending")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginRejectsOffOriginReturn(t *testing.T) {
|
func TestLoginRejectsOffOriginReturn(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
h := newHarness(t)
|
h := newHarness(t)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -153,6 +154,73 @@ func (c FlowCookie) Read(r *http.Request, v flowValue, now time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxPendingStates bounds each flow's pending states. The login route is
|
||||||
|
// unauthenticated, so without a bound anyone could grow the set without
|
||||||
|
// limit; one household never has close to this many flows in progress.
|
||||||
|
const maxPendingStates = 10000
|
||||||
|
|
||||||
|
// ErrTooManyPending means a flow's pending states are at maxPendingStates,
|
||||||
|
// so a new flow is refused until older ones expire.
|
||||||
|
var ErrTooManyPending = errors.New("too many sign-in flows in progress")
|
||||||
|
|
||||||
|
// PendingStates holds the state of every redirect flow started and not yet
|
||||||
|
// completed, until its flow cookie expires. A copy of a flow cookie verifies
|
||||||
|
// until it expires, so a flow is single-use only because its callback
|
||||||
|
// consumes the state here. The states live in memory — MealQuest runs as one
|
||||||
|
// process — so a flow started before a restart fails closed after it.
|
||||||
|
type PendingStates struct {
|
||||||
|
ttl time.Duration
|
||||||
|
mu sync.Mutex
|
||||||
|
states map[string]time.Time
|
||||||
|
// order is every added state in the order added. Every state is pending
|
||||||
|
// for the same ttl, so it is also expiry order and Add forgets expired
|
||||||
|
// states from its front.
|
||||||
|
order []pendingState
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingState struct {
|
||||||
|
state string
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPendingStates returns an empty set whose states are pending for ttl,
|
||||||
|
// the flow cookie's lifetime.
|
||||||
|
func NewPendingStates(ttl time.Duration) *PendingStates {
|
||||||
|
return &PendingStates{ttl: ttl, states: map[string]time.Time{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add records state, started at now, as pending and forgets expired ones.
|
||||||
|
// It fails with ErrTooManyPending when maxPendingStates are pending.
|
||||||
|
func (p *PendingStates) Add(state string, now time.Time) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
expired := 0
|
||||||
|
for expired < len(p.order) && !now.Before(p.order[expired].expires) {
|
||||||
|
delete(p.states, p.order[expired].state)
|
||||||
|
expired++
|
||||||
|
}
|
||||||
|
p.order = p.order[expired:]
|
||||||
|
// The bound counts every unexpired state, consumed or not: a consumed
|
||||||
|
// state leaves states at once but order only when it expires.
|
||||||
|
if len(p.order) >= maxPendingStates {
|
||||||
|
return ErrTooManyPending
|
||||||
|
}
|
||||||
|
expires := now.Add(p.ttl)
|
||||||
|
p.states[state] = expires
|
||||||
|
p.order = append(p.order, pendingState{state: state, expires: expires})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume removes state from the pending states, reporting whether it was
|
||||||
|
// there.
|
||||||
|
func (p *PendingStates) Consume(state string) bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
_, ok := p.states[state]
|
||||||
|
delete(p.states, state)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// returnPath validates the optional post-login return path. Empty means "/".
|
// returnPath validates the optional post-login return path. Empty means "/".
|
||||||
// Only same-origin absolute paths are allowed: no scheme, host, protocol-
|
// Only same-origin absolute paths are allowed: no scheme, host, protocol-
|
||||||
// relative "//" prefix, backslashes or control characters.
|
// relative "//" prefix, backslashes or control characters.
|
||||||
|
|||||||
@@ -255,13 +255,14 @@ type Session struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ShoppingGroup struct {
|
type ShoppingGroup struct {
|
||||||
ID string
|
ID string
|
||||||
ListID string
|
ListID string
|
||||||
DinnerID string
|
DinnerID string
|
||||||
Date string
|
Date string
|
||||||
Position int64
|
Position int64
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
|
FrozenDinnerName sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShoppingItem struct {
|
type ShoppingItem struct {
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ type Querier interface {
|
|||||||
// Records a pending or running batch as done or failed; a batch already
|
// Records a pending or running batch as done or failed; a batch already
|
||||||
// finished is left as it is (0 rows).
|
// finished is left as it is (0 rows).
|
||||||
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
||||||
|
// Keeps the name each dinner of list list_id has now as its group's
|
||||||
|
// frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||||
|
FreezeShoppingGroupNames(ctx context.Context, listID string) error
|
||||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||||
// frozen already.
|
// frozen already.
|
||||||
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
|
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
|
||||||
@@ -228,7 +231,8 @@ type Querier interface {
|
|||||||
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
|
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
|
||||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||||
// The groups of list list_id with their dinner's name, in list order.
|
// The groups of list list_id with their dinner's name now, in list order.
|
||||||
|
// A group of a frozen list shows its frozen_dinner_name instead.
|
||||||
ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error)
|
ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error)
|
||||||
// The groups of list list_id, in list order.
|
// The groups of list list_id, in list order.
|
||||||
ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error)
|
ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error)
|
||||||
|
|||||||
@@ -155,6 +155,19 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const freezeShoppingGroupNames = `-- name: FreezeShoppingGroupNames :exec
|
||||||
|
UPDATE shopping_groups
|
||||||
|
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||||
|
WHERE list_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
// Keeps the name each dinner of list list_id has now as its group's
|
||||||
|
// frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||||
|
func (q *Queries) FreezeShoppingGroupNames(ctx context.Context, listID string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, freezeShoppingGroupNames, listID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
|
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
|
||||||
UPDATE shopping_lists
|
UPDATE shopping_lists
|
||||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||||
@@ -235,7 +248,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listShoppingGroupDinners = `-- name: ListShoppingGroupDinners :many
|
const listShoppingGroupDinners = `-- name: ListShoppingGroupDinners :many
|
||||||
SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, dinners.name AS dinner_name
|
SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, shopping_groups.frozen_dinner_name, dinners.name AS dinner_name
|
||||||
FROM shopping_groups
|
FROM shopping_groups
|
||||||
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
||||||
WHERE shopping_groups.list_id = ?
|
WHERE shopping_groups.list_id = ?
|
||||||
@@ -247,7 +260,8 @@ type ListShoppingGroupDinnersRow struct {
|
|||||||
DinnerName string
|
DinnerName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// The groups of list list_id with their dinner's name, in list order.
|
// The groups of list list_id with their dinner's name now, in list order.
|
||||||
|
// A group of a frozen list shows its frozen_dinner_name instead.
|
||||||
func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) {
|
func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID)
|
rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -265,6 +279,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
|
|||||||
&i.ShoppingGroup.Position,
|
&i.ShoppingGroup.Position,
|
||||||
&i.ShoppingGroup.CreatedAt,
|
&i.ShoppingGroup.CreatedAt,
|
||||||
&i.ShoppingGroup.UpdatedAt,
|
&i.ShoppingGroup.UpdatedAt,
|
||||||
|
&i.ShoppingGroup.FrozenDinnerName,
|
||||||
&i.DinnerName,
|
&i.DinnerName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -281,7 +296,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listShoppingGroups = `-- name: ListShoppingGroups :many
|
const listShoppingGroups = `-- name: ListShoppingGroups :many
|
||||||
SELECT id, list_id, dinner_id, date, position, created_at, updated_at FROM shopping_groups
|
SELECT id, list_id, dinner_id, date, position, created_at, updated_at, frozen_dinner_name FROM shopping_groups
|
||||||
WHERE list_id = ?
|
WHERE list_id = ?
|
||||||
ORDER BY position, id
|
ORDER BY position, id
|
||||||
`
|
`
|
||||||
@@ -304,6 +319,7 @@ func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]Shop
|
|||||||
&i.Position,
|
&i.Position,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FrozenDinnerName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,10 +137,9 @@ type Service struct {
|
|||||||
account sync.Mutex
|
account sync.Mutex
|
||||||
|
|
||||||
// pending holds the state of every connect started and not yet
|
// pending holds the state of every connect started and not yet
|
||||||
// completed, with when it expires. Complete consumes a state once, so a
|
// completed. Complete consumes a state once, so a replayed cookie finds
|
||||||
// replayed cookie finds it gone.
|
// it gone.
|
||||||
pendingMu sync.Mutex
|
pending *auth.PendingStates
|
||||||
pending map[string]time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService returns the service over st, sealing refresh tokens with box.
|
// NewService returns the service over st, sealing refresh tokens with box.
|
||||||
@@ -175,7 +174,7 @@ func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, erro
|
|||||||
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
|
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
|
||||||
retryDelay: firstRetryDelay,
|
retryDelay: firstRetryDelay,
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
pending: map[string]time.Time{},
|
pending: auth.NewPendingStates(connectCookieTTL),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,10 +209,12 @@ func (s *Service) Connect(w http.ResponseWriter, session auth.Session) (string,
|
|||||||
Session: sessionBinding(session),
|
Session: sessionBinding(session),
|
||||||
}
|
}
|
||||||
now := s.now()
|
now := s.now()
|
||||||
|
if err := s.pending.Add(state.State, now); err != nil {
|
||||||
|
return "", fmt.Errorf("gcal: %w", err)
|
||||||
|
}
|
||||||
if err := s.cookie.Set(w, &state, now); err != nil {
|
if err := s.cookie.Set(w, &state, now); err != nil {
|
||||||
return "", fmt.Errorf("gcal: set connect cookie: %w", err)
|
return "", fmt.Errorf("gcal: set connect cookie: %w", err)
|
||||||
}
|
}
|
||||||
s.addPending(state.State, now)
|
|
||||||
return s.oauth.AuthCodeURL(state.State,
|
return s.oauth.AuthCodeURL(state.State,
|
||||||
oauth2.AccessTypeOffline,
|
oauth2.AccessTypeOffline,
|
||||||
oauth2.ApprovalForce,
|
oauth2.ApprovalForce,
|
||||||
@@ -243,7 +244,7 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
|
|||||||
}
|
}
|
||||||
// A copy of the cookie verifies until it expires, so the state is
|
// A copy of the cookie verifies until it expires, so the state is
|
||||||
// single-use only because it is consumed here.
|
// single-use only because it is consumed here.
|
||||||
if !s.consumePending(state.State) {
|
if !s.pending.Consume(state.State) {
|
||||||
return fmt.Errorf("%w: already used", ErrBadState)
|
return fmt.Errorf("%w: already used", ErrBadState)
|
||||||
}
|
}
|
||||||
if q.Get("error") != "" {
|
if q.Get("error") != "" {
|
||||||
@@ -276,29 +277,6 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
|
|||||||
return s.store(ctx, sealed)
|
return s.store(ctx, sealed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// addPending records state, started at now, as pending until the connect
|
|
||||||
// cookie expires, and forgets expired ones.
|
|
||||||
func (s *Service) addPending(state string, now time.Time) {
|
|
||||||
s.pendingMu.Lock()
|
|
||||||
defer s.pendingMu.Unlock()
|
|
||||||
for k, expires := range s.pending {
|
|
||||||
if !now.Before(expires) {
|
|
||||||
delete(s.pending, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.pending[state] = now.Add(connectCookieTTL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// consumePending removes state from the pending states, reporting whether
|
|
||||||
// it was there.
|
|
||||||
func (s *Service) consumePending(state string) bool {
|
|
||||||
s.pendingMu.Lock()
|
|
||||||
defer s.pendingMu.Unlock()
|
|
||||||
_, ok := s.pending[state]
|
|
||||||
delete(s.pending, state)
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkScopes fails with ErrScopes unless token's granted scopes include
|
// checkScopes fails with ErrScopes unless token's granted scopes include
|
||||||
// every scope in Scopes. Google lets the household untick a permission on
|
// every scope in Scopes. Google lets the household untick a permission on
|
||||||
// the consent screen and reports what was granted in "scope".
|
// the consent screen and reports what was granted in "scope".
|
||||||
|
|||||||
@@ -111,10 +111,13 @@ type pushedPlan struct {
|
|||||||
// plan Calendar does not show and pushing again makes it; or
|
// plan Calendar does not show and pushing again makes it; or
|
||||||
// supply_run_failed when it could not be made, and POST
|
// supply_run_failed when it could not be made, and POST
|
||||||
// /shopping/{weekStart}/generate makes it again. error is null when status
|
// /shopping/{weekStart}/generate makes it again. error is null when status
|
||||||
// is generated.
|
// is generated. title_job_id is the job titling a generated Supply run,
|
||||||
|
// which GET /jobs/{id} reports on; null when it already has its title or
|
||||||
|
// was not generated.
|
||||||
type pushedSupplyRun struct {
|
type pushedSupplyRun struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Error *apiError `json:"error"`
|
Error *apiError `json:"error"`
|
||||||
|
TitleJobID *string `json:"title_job_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// postPlanPush pushes the week's plan to the chosen Google calendar
|
// postPlanPush pushes the week's plan to the chosen Google calendar
|
||||||
@@ -143,7 +146,9 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
|||||||
// gone.
|
// gone.
|
||||||
ctx := context.WithoutCancel(r.Context())
|
ctx := context.WithoutCancel(r.Context())
|
||||||
run := pushedSupplyRun{Status: "generated"}
|
run := pushedSupplyRun{Status: "generated"}
|
||||||
if _, err := shop.GeneratePushed(ctx, week, revision); errors.Is(err, shopping.ErrFrozen) {
|
if g, err := shop.GeneratePushed(ctx, week, revision); err == nil {
|
||||||
|
run.TitleJobID = g.TitleJobID
|
||||||
|
} else if errors.Is(err, shopping.ErrFrozen) {
|
||||||
logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String())
|
logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String())
|
||||||
run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}}
|
run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}}
|
||||||
} else if errors.Is(err, shopping.ErrPlanChanged) {
|
} else if errors.Is(err, shopping.ErrPlanChanged) {
|
||||||
@@ -153,7 +158,7 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
|||||||
Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||||
Retryable: true,
|
Retryable: true,
|
||||||
}}
|
}}
|
||||||
} else if err != nil {
|
} else {
|
||||||
logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err)
|
logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err)
|
||||||
run = pushedSupplyRun{Status: "failed", Error: &apiError{
|
run = pushedSupplyRun{Status: "failed", Error: &apiError{
|
||||||
Code: "supply_run_failed",
|
Code: "supply_run_failed",
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
|
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
|
||||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestDinnersOn(t, st), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,12 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
|
|||||||
// with its own unstarted job runner and a grounder that finds nothing.
|
// with its own unstarted job runner and a grounder that finds nothing.
|
||||||
func newTestDinners(t *testing.T) *dinners.Service {
|
func newTestDinners(t *testing.T) *dinners.Service {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st := newTestStore(t)
|
return newTestDinnersOn(t, newTestStore(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestDinnersOn is newTestDinners over st.
|
||||||
|
func newTestDinnersOn(t *testing.T, st *store.Store) *dinners.Service {
|
||||||
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
runner, err := jobs.NewRunner(st, logger, 1)
|
runner, err := jobs.NewRunner(st, logger, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -60,10 +60,13 @@ type supplyRunItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
|
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
|
||||||
// the Supply run as made, and the plan days with nothing to shop for.
|
// the Supply run as made, the plan days with nothing to shop for, and the
|
||||||
|
// title_job_id of the job titling it, which GET /jobs/{id} reports on;
|
||||||
|
// null when the Supply run already has its title.
|
||||||
type generatedSupplyRun struct {
|
type generatedSupplyRun struct {
|
||||||
supplyRun
|
supplyRun
|
||||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||||
|
TitleJobID *string `json:"title_job_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
|
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
|
||||||
@@ -108,7 +111,7 @@ func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its
|
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its
|
||||||
// plan and answers with it and the plan days it skipped.
|
// plan and answers with it, the plan days it skipped and its title job.
|
||||||
func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
week, ok := weekStartOrFail(w, r, logger)
|
week, ok := weekStartOrFail(w, r, logger)
|
||||||
@@ -129,7 +132,7 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
|
|||||||
if skipped == nil {
|
if skipped == nil {
|
||||||
skipped = []shopping.SkippedDay{}
|
skipped = []shopping.SkippedDay{}
|
||||||
}
|
}
|
||||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped})
|
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped, TitleJobID: g.TitleJobID})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ func (e plansEnv) groundWeek(t *testing.T) {
|
|||||||
func (e plansEnv) archive(t *testing.T) {
|
func (e plansEnv) archive(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||||
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
|
|||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("generate = %d %v", status, raw)
|
t.Fatalf("generate = %d %v", status, raw)
|
||||||
}
|
}
|
||||||
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"}
|
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "title_job_id", "total_count", "weekStart"}
|
||||||
if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
|
if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
|
||||||
t.Errorf("generate fields = %v, want %v", got, wantTop)
|
t.Errorf("generate fields = %v, want %v", got, wantTop)
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,7 @@ func TestSupplyRunShape(t *testing.T) {
|
|||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("GET = %d %v", status, raw)
|
t.Fatalf("GET = %d %v", status, raw)
|
||||||
}
|
}
|
||||||
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" })) {
|
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" || s == "title_job_id" })) {
|
||||||
t.Errorf("GET fields = %v", k)
|
t.Errorf("GET fields = %v", k)
|
||||||
}
|
}
|
||||||
group := raw["groups"].([]any)[0].(map[string]any)
|
group := raw["groups"].([]any)[0].(map[string]any)
|
||||||
@@ -376,8 +376,12 @@ func TestPushPlanMakesSupplyRun(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||||
}
|
}
|
||||||
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
|
run, _ := body["supply_run"].(map[string]any)
|
||||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
if k := keys(run); run["status"] != "generated" || run["error"] != nil || !reflect.DeepEqual(k, []string{"error", "status", "title_job_id"}) {
|
||||||
|
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||||
|
}
|
||||||
|
if job := titleJob(t, e, run["title_job_id"]); job["status"] != "queued" {
|
||||||
|
t.Errorf("title job = %v, want queued: no runner in this test", job)
|
||||||
}
|
}
|
||||||
var l supplyRunJSON
|
var l supplyRunJSON
|
||||||
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK {
|
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK {
|
||||||
@@ -467,9 +471,11 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||||
}
|
}
|
||||||
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
|
run, _ := body["supply_run"].(map[string]any)
|
||||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
if run["status"] != "generated" || run["error"] != nil {
|
||||||
|
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||||
}
|
}
|
||||||
|
titleJobID, _ := run["title_job_id"].(string)
|
||||||
held.waitEntered(t)
|
held.waitEntered(t)
|
||||||
var l supplyRunJSON
|
var l supplyRunJSON
|
||||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||||
@@ -478,6 +484,7 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
close(held.release)
|
close(held.release)
|
||||||
|
wantTitleJobSucceeded(t, e, titleJobID)
|
||||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||||
t.Errorf("title = %q, want the title job's", got)
|
t.Errorf("title = %q, want the title job's", got)
|
||||||
}
|
}
|
||||||
@@ -493,7 +500,8 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
|||||||
e.startRunner(t)
|
e.startRunner(t)
|
||||||
|
|
||||||
var l supplyRunJSON
|
var l supplyRunJSON
|
||||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
|
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
|
||||||
|
if status != http.StatusOK {
|
||||||
t.Fatalf("generate = %d %v", status, body)
|
t.Fatalf("generate = %d %v", status, body)
|
||||||
}
|
}
|
||||||
if l.Title != nil {
|
if l.Title != nil {
|
||||||
@@ -502,12 +510,66 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(l.render(), weekLines) {
|
if !reflect.DeepEqual(l.render(), weekLines) {
|
||||||
t.Errorf("generated = %v, want %v", l.render(), weekLines)
|
t.Errorf("generated = %v, want %v", l.render(), weekLines)
|
||||||
}
|
}
|
||||||
|
titleJobID, _ := body["title_job_id"].(string)
|
||||||
held.waitEntered(t)
|
held.waitEntered(t)
|
||||||
|
|
||||||
close(held.release)
|
close(held.release)
|
||||||
|
wantTitleJobSucceeded(t, e, titleJobID)
|
||||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||||
t.Errorf("title = %q, want the title job's", got)
|
t.Errorf("title = %q, want the title job's", got)
|
||||||
}
|
}
|
||||||
|
if _, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil); body["title_job_id"] != nil {
|
||||||
|
t.Errorf("remake title_job_id = %v, want null: the Supply run has its title", body["title_job_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSupplyRunGenerateTitleJobFails: a title job that cannot title the
|
||||||
|
// Supply run is reported failed with title_failed by GET /jobs/{id}, and
|
||||||
|
// the week's next generation answers with a new title job.
|
||||||
|
func TestSupplyRunGenerateTitleJobFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
e.groundWeek(t)
|
||||||
|
e.startRunner(t)
|
||||||
|
|
||||||
|
_, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||||
|
first := titleJob(t, e, body["title_job_id"])
|
||||||
|
job := waitJob(t, e.h, first["id"].(string))
|
||||||
|
if job["status"] != "failed" || job["error"] != shopping.ReasonTitleFailed {
|
||||||
|
t.Errorf("title job = %v, want failed %s", job, shopping.ReasonTitleFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||||
|
if second := titleJob(t, e, body["title_job_id"]); second["id"] == first["id"] {
|
||||||
|
t.Errorf("retry title_job_id = %v, want a new job", second["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// titleJob returns the title_supply_run job id, as GET /jobs/{id} reports
|
||||||
|
// it; id must be a job id string.
|
||||||
|
func titleJob(t *testing.T, e plansEnv, id any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
s, ok := id.(string)
|
||||||
|
if !ok || s == "" {
|
||||||
|
t.Fatalf("title_job_id = %v, want a job id", id)
|
||||||
|
}
|
||||||
|
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+s, "")
|
||||||
|
if rec.Code != http.StatusOK || job["kind"] != shopping.KindTitleSupplyRun {
|
||||||
|
t.Fatalf("GET title job = %d %s, want a %s job", rec.Code, rec.Body, shopping.KindTitleSupplyRun)
|
||||||
|
}
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantTitleJobSucceeded waits for title job id to finish and fails t
|
||||||
|
// unless GET /jobs/{id} reports it succeeded with the title written.
|
||||||
|
func wantTitleJobSucceeded(t *testing.T, e plansEnv, id string) {
|
||||||
|
t.Helper()
|
||||||
|
titleJob(t, e, id)
|
||||||
|
job := waitJob(t, e.h, id)
|
||||||
|
result, _ := job["result"].(map[string]any)
|
||||||
|
if job["status"] != "succeeded" || result["title"] != "Chili and Friends" {
|
||||||
|
t.Errorf("title job = %v, want succeeded with the title", job)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
|
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
|
||||||
@@ -546,7 +608,7 @@ func TestPushPlanSupplyRunFails(t *testing.T) {
|
|||||||
"code": "supply_run_failed",
|
"code": "supply_run_failed",
|
||||||
"message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
|
"message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
|
||||||
"retryable": true,
|
"retryable": true,
|
||||||
}}
|
}, "title_job_id": nil}
|
||||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||||
}
|
}
|
||||||
@@ -599,7 +661,7 @@ func TestPushPlanEditedBeforeSupplyRun(t *testing.T) {
|
|||||||
"code": "plan_changed",
|
"code": "plan_changed",
|
||||||
"message": "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
"message": "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||||
"retryable": true,
|
"retryable": true,
|
||||||
}}
|
}, "title_job_id": nil}
|
||||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||||
}
|
}
|
||||||
@@ -632,7 +694,7 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
|
|||||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||||
}
|
}
|
||||||
run, _ := body["supply_run"].(map[string]any)
|
run, _ := body["supply_run"].(map[string]any)
|
||||||
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" {
|
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" || run["title_job_id"] != nil {
|
||||||
t.Errorf("supply_run = %v, want archived", body["supply_run"])
|
t.Errorf("supply_run = %v, want archived", body["supply_run"])
|
||||||
}
|
}
|
||||||
var l supplyRunJSON
|
var l supplyRunJSON
|
||||||
@@ -790,6 +852,73 @@ func TestSupplyArchive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSupplyArchiveKeepsFrozenNames freezes last week's Supply run, then
|
||||||
|
// renames its dinner and this week's through the dinners API: the frozen
|
||||||
|
// post and list keep the name the dinner had when frozen, while this
|
||||||
|
// week's list, not frozen, shows the new name.
|
||||||
|
func TestSupplyArchiveKeepsFrozenNames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
e := newPlansRouter(t)
|
||||||
|
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"})
|
||||||
|
current := e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"lemon", "1", ""})
|
||||||
|
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for id, name := range map[string]string{
|
||||||
|
last.Groups[0].Dinner.ID: "Sunday Beef Stew",
|
||||||
|
current.Groups[0].Dinner.ID: "Lemon Garlic Chicken",
|
||||||
|
} {
|
||||||
|
if status, body := callShopping(t, e, http.MethodPatch, dinnersPath+"/"+id, `{"name":"`+name+`"}`, nil); status != http.StatusOK {
|
||||||
|
t.Fatalf("PATCH dinner %s = %d %v", id, status, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var got archiveJSON
|
||||||
|
if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK {
|
||||||
|
t.Fatalf("GET archive = %d %v", status, raw)
|
||||||
|
}
|
||||||
|
var posts []string
|
||||||
|
for _, p := range got.Periods {
|
||||||
|
for _, post := range p.Posts {
|
||||||
|
posts = append(posts, fmt.Sprintf("%s %v current=%t", post.WeekStart, post.Dinners, post.Current))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
thisWeek.String() + " [Lemon Garlic Chicken] current=true",
|
||||||
|
lastWeek.String() + " [Beef Stew] current=false",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(posts, want) {
|
||||||
|
t.Errorf("archive after rename =\n %s\nwant\n %s", strings.Join(posts, "\n "), strings.Join(want, "\n "))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
week store.LocalDate
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{lastWeek, "Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed"},
|
||||||
|
{thisWeek, "Monday " + thisWeek.String() + " Lemon Garlic Chicken: lemon [1] needed"},
|
||||||
|
} {
|
||||||
|
var l supplyRunJSON
|
||||||
|
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+tc.week.String(), "", &l); status != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s = %d %v", tc.week, status, body)
|
||||||
|
}
|
||||||
|
if got := l.render(); !reflect.DeepEqual(got, []string{tc.want}) {
|
||||||
|
t.Errorf("list %s after rename = %v, want [%s]", tc.week, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once,
|
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once,
|
||||||
// records sent_to_chat_at, answers a second send with the status as it
|
// records sent_to_chat_at, answers a second send with the status as it
|
||||||
// is, and posts again only with resend.
|
// is, and posts again only with resend.
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ const (
|
|||||||
|
|
||||||
// Freeze moves the Supply run of the week starting weekStart into the
|
// Freeze moves the Supply run of the week starting weekStart into the
|
||||||
// Supply archive: its status becomes archived and frozen_at is set, after
|
// Supply archive: its status becomes archived and frozen_at is set, after
|
||||||
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
|
// which it is read-only (Generate and SetNeeded are ErrFrozen). The list
|
||||||
|
// keeps the names its dinners have as it is frozen, so its post in the
|
||||||
|
// Supply archive shows them whatever a dinner is renamed later. Freezing a
|
||||||
// frozen list does nothing and is no error.
|
// frozen list does nothing and is no error.
|
||||||
//
|
//
|
||||||
// The week must have ended in the household's time zone: a week not yet
|
// The week must have ended in the household's time zone: a week not yet
|
||||||
@@ -79,14 +81,25 @@ func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now tim
|
|||||||
"week", list.WeekStart, "list", list.ID, "error", err)
|
"week", list.WeekStart, "list", list.ID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
// A list frozen meanwhile is left as it is, its names as they were
|
||||||
|
// frozen: the freeze is done.
|
||||||
stamp := store.Now().String()
|
stamp := store.Now().String()
|
||||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
q := db.New(tx)
|
||||||
}); err != nil {
|
n, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
||||||
}
|
})
|
||||||
return nil
|
if err != nil {
|
||||||
|
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := q.FreezeShoppingGroupNames(ctx, list.ID); err != nil {
|
||||||
|
return fmt.Errorf("shopping: freeze %s: keep dinner names: %w", weekStart, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// freezeTitleJob is the title job of list as Generate would enqueue it:
|
// freezeTitleJob is the title job of list as Generate would enqueue it:
|
||||||
@@ -145,7 +158,8 @@ type Post struct {
|
|||||||
Label string
|
Label string
|
||||||
// Title is nil while the list is untitled, when Label stands in.
|
// Title is nil while the list is untitled, when Label stands in.
|
||||||
Title *string
|
Title *string
|
||||||
// Dinners are the names of the list's dinners, in day order.
|
// Dinners are the names of the list's dinners, in day order: as they
|
||||||
|
// were when the list was frozen, or as they are now while it is not.
|
||||||
Dinners []string
|
Dinners []string
|
||||||
// Items is how many items the list has, and OnHand how many of them
|
// Items is how many items the list has, and OnHand how many of them
|
||||||
// the household marked "have it".
|
// the household marked "have it".
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ func TestArchiveGroups(t *testing.T) {
|
|||||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
|
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)
|
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 {
|
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'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) {
|
|||||||
t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
|
t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
posts := len(e.chat.Posts())
|
posts := len(e.chat.Posts())
|
||||||
|
|||||||
@@ -492,7 +492,9 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e
|
|||||||
return readList(ctx, q, list)
|
return readList(ctx, q, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readList reads the groups and items of list through q.
|
// readList reads the groups and items of list through q. A group of a
|
||||||
|
// frozen list is named as its dinner was when the list was frozen; one
|
||||||
|
// with no such name is an error.
|
||||||
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
|
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
|
||||||
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
|
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -512,8 +514,15 @@ func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, e
|
|||||||
groups := make([]Group, 0, len(rows))
|
groups := make([]Group, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
g := r.ShoppingGroup
|
g := r.ShoppingGroup
|
||||||
|
name := r.DinnerName
|
||||||
|
if frozen(list) {
|
||||||
|
if !g.FrozenDinnerName.Valid {
|
||||||
|
return List{}, fmt.Errorf("shopping: group %s of frozen list %s has no frozen dinner name", g.ID, list.WeekStart)
|
||||||
|
}
|
||||||
|
name = g.FrozenDinnerName.String
|
||||||
|
}
|
||||||
groups = append(groups, Group{
|
groups = append(groups, Group{
|
||||||
ID: g.ID, DinnerID: g.DinnerID, DinnerName: r.DinnerName, Date: g.Date, Position: int(g.Position),
|
ID: g.ID, DinnerID: g.DinnerID, DinnerName: name, Date: g.Date, Position: int(g.Position),
|
||||||
Items: byGroup[g.ID],
|
Items: byGroup[g.ID],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -692,7 +692,7 @@ func TestGenerateRefusesFrozenList(t *testing.T) {
|
|||||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||||
e.plan(t, "Beef Chili")
|
e.plan(t, "Beef Chili")
|
||||||
e.generate(t)
|
e.generate(t)
|
||||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
e.swap(t, "2026-10-12", "Pad Thai")
|
e.swap(t, "2026-10-12", "Pad Thai")
|
||||||
@@ -834,7 +834,7 @@ func TestSetNeededRefusesFrozenList(t *testing.T) {
|
|||||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||||
e.plan(t, "Beef Chili")
|
e.plan(t, "Beef Chili")
|
||||||
l := e.generate(t).List
|
l := e.generate(t).List
|
||||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) {
|
|||||||
e := titleWeek(t)
|
e := titleWeek(t)
|
||||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||||
e.beforeTitle = func() {
|
e.beforeTitle = func() {
|
||||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,3 +311,38 @@ func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
|
|||||||
t.Errorf("stored title = %v, want one of the two titles", deref(got))
|
t.Errorf("stored title = %v, want one of the two titles", deref(got))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGenerateTitleNotStoredOnReplacedList: a list replaced by a new
|
||||||
|
// untitled list of the same week once archive_title has answered, before
|
||||||
|
// its title is stored, keeps the replacement untitled; the old list's
|
||||||
|
// title job fails, and the failure is logged.
|
||||||
|
func TestGenerateTitleNotStoredOnReplacedList(t *testing.T) {
|
||||||
|
e := titleWeek(t)
|
||||||
|
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||||
|
e.afterTitle = func() {
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`DELETE FROM shopping_items WHERE group_id IN (SELECT id FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1))`,
|
||||||
|
`DELETE FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1)`,
|
||||||
|
`UPDATE shopping_lists SET id = 'replacement' WHERE week_start = ?1`,
|
||||||
|
} {
|
||||||
|
if _, err := e.st.Writer.Exec(stmt, week.String()); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g := e.generate(t)
|
||||||
|
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
if err := e.st.Reader.QueryRow(`SELECT id FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id != "replacement" || id == g.List.ID {
|
||||||
|
t.Fatalf("week's list = %s, want the replacement of %s", id, g.List.ID)
|
||||||
|
}
|
||||||
|
wantUntitled(t, "replacement's title", e.storedTitle(t))
|
||||||
|
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
|
||||||
|
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
|||||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
|
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name" {
|
||||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
|||||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
|
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name" {
|
||||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0019_shopping_group_frozen_name", got)
|
||||||
}
|
}
|
||||||
if !tableExists(t, s, "dinners") {
|
if !tableExists(t, s, "dinners") {
|
||||||
t.Error("table from 0001_init was not created")
|
t.Error("table from 0001_init was not created")
|
||||||
@@ -922,3 +922,57 @@ func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) {
|
|||||||
t.Errorf("shopping list after 0017 = %s, want %s", got, want)
|
t.Errorf("shopping list after 0017 = %s, want %s", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMigrateShoppingGroupFrozenName applies 0019 over a frozen list and
|
||||||
|
// a current one: the frozen list's group takes its dinner's name now, the
|
||||||
|
// current list's group has none, and a frozen name is never empty.
|
||||||
|
func TestMigrateShoppingGroupFrozenName(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := openTestStore(t)
|
||||||
|
migrateBefore(t, s, "0019")
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
|
||||||
|
VALUES ('dinner-1', 'Beef Stew', 'beef stew', 'regular', 'regular', 0, 'manual', 'c', 'u'),
|
||||||
|
('dinner-2', 'Lemon Chicken', 'lemon chicken', 'regular', 'regular', 0, 'manual', 'c', 'u')`,
|
||||||
|
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at)
|
||||||
|
VALUES ('plan-1', '2026-10-05', 'synced', 'c', 'u'),
|
||||||
|
('plan-2', '2026-10-12', 'synced', 'c', 'u')`,
|
||||||
|
`INSERT INTO shopping_lists (id, week_start, plan_id, status, frozen_at, created_at, updated_at)
|
||||||
|
VALUES ('list-1', '2026-10-05', 'plan-1', 'archived', 'f', 'c', 'u'),
|
||||||
|
('list-2', '2026-10-12', 'plan-2', 'current', NULL, 'c', 'u')`,
|
||||||
|
`INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
|
||||||
|
VALUES ('group-1', 'list-1', 'dinner-1', '2026-10-05', 0, 'c', 'u'),
|
||||||
|
('group-2', 'list-2', 'dinner-2', '2026-10-12', 0, 'c', 'u')`,
|
||||||
|
} {
|
||||||
|
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.Reader.QueryContext(ctx, `SELECT id || '|' || COALESCE(frozen_dinner_name, 'NULL') || '|' || updated_at FROM shopping_groups ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var got []string
|
||||||
|
for rows.Next() {
|
||||||
|
var row string
|
||||||
|
if err := rows.Scan(&row); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got = append(got, row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := []string{"group-1|Beef Stew|u", "group-2|NULL|u"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Errorf("shopping groups after 0019 = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if _, err := s.Writer.Exec(`UPDATE shopping_groups SET frozen_dinner_name = '' WHERE id = 'group-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
|
||||||
|
t.Errorf("empty frozen name: error %v, want CHECK constraint failed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- 0019_shopping_group_frozen_name: a Supply run frozen into the Supply
|
||||||
|
-- archive keeps the names its dinners had when it was frozen. A dinner can
|
||||||
|
-- be renamed at any time, so a frozen list reads each group's dinner name
|
||||||
|
-- from frozen_dinner_name, never from the dinner, as 0012 does for a Pass.
|
||||||
|
-- Only a group of a frozen list has one; a current list reads its dinners'
|
||||||
|
-- names live. A list frozen before this takes its dinners' names now.
|
||||||
|
|
||||||
|
ALTER TABLE shopping_groups ADD COLUMN frozen_dinner_name TEXT CHECK (frozen_dinner_name <> '');
|
||||||
|
|
||||||
|
UPDATE shopping_groups
|
||||||
|
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||||
|
WHERE list_id IN (
|
||||||
|
SELECT id FROM shopping_lists WHERE status = 'archived' OR frozen_at IS NOT NULL
|
||||||
|
);
|
||||||
@@ -60,7 +60,8 @@ DELETE FROM shopping_items
|
|||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: ListShoppingGroupDinners :many
|
-- name: ListShoppingGroupDinners :many
|
||||||
-- The groups of list list_id with their dinner's name, in list order.
|
-- The groups of list list_id with their dinner's name now, in list order.
|
||||||
|
-- A group of a frozen list shows its frozen_dinner_name instead.
|
||||||
SELECT sqlc.embed(shopping_groups), dinners.name AS dinner_name
|
SELECT sqlc.embed(shopping_groups), dinners.name AS dinner_name
|
||||||
FROM shopping_groups
|
FROM shopping_groups
|
||||||
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
||||||
@@ -117,6 +118,13 @@ UPDATE shopping_lists
|
|||||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||||
WHERE id = ? AND frozen_at IS NULL;
|
WHERE id = ? AND frozen_at IS NULL;
|
||||||
|
|
||||||
|
-- name: FreezeShoppingGroupNames :exec
|
||||||
|
-- Keeps the name each dinner of list list_id has now as its group's
|
||||||
|
-- frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||||
|
UPDATE shopping_groups
|
||||||
|
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||||
|
WHERE list_id = ?;
|
||||||
|
|
||||||
-- name: ListShoppingListsThrough :many
|
-- name: ListShoppingListsThrough :many
|
||||||
-- The Supply runs of the weeks starting on or before week_start, newest
|
-- The Supply runs of the weeks starting on or before week_start, newest
|
||||||
-- week first.
|
-- week first.
|
||||||
|
|||||||
@@ -1392,6 +1392,64 @@ describe('PlanPage', () => {
|
|||||||
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("still shows a swap's failure on coming back to the week, through another, and asks for the swap again only on Retry", async () => {
|
||||||
|
const fetchMock = stubServer({
|
||||||
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||||
|
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||||
|
[`GET /api/v1/plans/2026-10-19`]: () => errorResponse(404, 'not_found'),
|
||||||
|
...lists,
|
||||||
|
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_not_plannable'),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ }))
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||||
|
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-19'))
|
||||||
|
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-12'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
|
||||||
|
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||||
|
expect(puts(fetchMock)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the plan again when the dinner is already on another day, and offers to choose another rather than send it again', async () => {
|
||||||
|
// Meanwhile lemon orzo was planned on Wednesday.
|
||||||
|
let read = planBody('draft')
|
||||||
|
const fetchMock = stubServer({
|
||||||
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, read),
|
||||||
|
...lists,
|
||||||
|
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
const sheet = await openFriday()
|
||||||
|
read = planBody('draft')
|
||||||
|
read.days[2] = { ...read.days[2]!, dinner: { ...read.days[2]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true }
|
||||||
|
fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ }))
|
||||||
|
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. Lemon orzo is already on another day this week.")
|
||||||
|
expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||||
|
await waitFor(() => expect(within(tile('WED · NEW')).getByText('Lemon orzo')).toBeInTheDocument())
|
||||||
|
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Choosing again opens Friday's sheet on the plan read, where lemon
|
||||||
|
// orzo is already this week; nothing is sent again.
|
||||||
|
fireEvent.click(within(alert).getByRole('button', { name: 'Choose another dinner' }))
|
||||||
|
const again = screen.getByRole('dialog', { name: 'Swap Friday' })
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
const orzo = within(within(again).getByRole('list', { name: 'Quest log' })).getByRole('listitem')
|
||||||
|
expect(within(orzo).getByText('Already this week')).toBeInTheDocument()
|
||||||
|
expect(within(orzo).queryByRole('button')).not.toBeInTheDocument()
|
||||||
|
expect(puts(fetchMock)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('waits for a pending swap before Rebuild week, keeping its failure', async () => {
|
it('waits for a pending swap before Rebuild week, keeping its failure', async () => {
|
||||||
let refuse!: () => void
|
let refuse!: () => void
|
||||||
const fetchMock = stubServer({
|
const fetchMock = stubServer({
|
||||||
|
|||||||
@@ -127,6 +127,12 @@ export function ago(at: Date, now: Date): string {
|
|||||||
return days === 1 ? '1 day ago' : `${days} days ago`
|
return days === 1 ? '1 day ago' : `${days} days ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// alreadyPlanned says whether a swap was refused because its dinner is
|
||||||
|
// already on another day of the week: sending it again cannot succeed.
|
||||||
|
function alreadyPlanned(error: Error): boolean {
|
||||||
|
return error instanceof ApiError && error.code === 'dinner_already_planned'
|
||||||
|
}
|
||||||
|
|
||||||
// pushedToCalendar says whether drafting over a plan of this status needs
|
// pushedToCalendar says whether drafting over a plan of this status needs
|
||||||
// confirm: it was pushed to Calendar.
|
// confirm: it was pushed to Calendar.
|
||||||
function pushedToCalendar(status: PlanStatus): boolean {
|
function pushedToCalendar(status: PlanStatus): boolean {
|
||||||
@@ -398,9 +404,18 @@ function WeekBoard({
|
|||||||
</ErrorBanner>
|
</ErrorBanner>
|
||||||
)
|
)
|
||||||
) : swap.failed !== null ? (
|
) : swap.failed !== null ? (
|
||||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
alreadyPlanned(swap.failed.error) ? (
|
||||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
// The plan is read again (useSwapDay): the household chooses another
|
||||||
</ErrorBanner>
|
// dinner from it rather than sending the refused swap again.
|
||||||
|
<ErrorBanner onRetry={() => openSwap(swap.failed!.swap.date)} retryLabel="Choose another dinner">
|
||||||
|
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.swap.dinner.name} is already on another day
|
||||||
|
this week.
|
||||||
|
</ErrorBanner>
|
||||||
|
) : (
|
||||||
|
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||||
|
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)
|
||||||
) : startDraft.isError ? (
|
) : startDraft.isError ? (
|
||||||
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
||||||
) : result.error !== null ? (
|
) : result.error !== null ? (
|
||||||
|
|||||||
@@ -127,6 +127,17 @@ describe('usePlan', () => {
|
|||||||
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
|
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
|
||||||
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
|
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
|
||||||
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
|
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
|
||||||
|
// Date reads both, in this device's zone: neither says which instant.
|
||||||
|
[
|
||||||
|
'a sync time with no offset',
|
||||||
|
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07T15:58:00'),
|
||||||
|
'calendar_synced_at "2026-10-07T15:58:00" is not an instant',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'a sync date with no time',
|
||||||
|
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07'),
|
||||||
|
'calendar_synced_at "2026-10-07" is not an instant',
|
||||||
|
],
|
||||||
])('refuses a plan with %s', async (_, change, said) => {
|
])('refuses a plan with %s', async (_, change, said) => {
|
||||||
const body = planBody()
|
const body = planBody()
|
||||||
change(body)
|
change(body)
|
||||||
@@ -208,6 +219,72 @@ describe('useSwapDay', () => {
|
|||||||
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('leaves a day a later plan read landed, the very day the swap wrote, when the swap of it fails', async () => {
|
||||||
|
const friday = deferred()
|
||||||
|
let read = planBody()
|
||||||
|
stubServer({
|
||||||
|
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||||
|
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||||
|
})
|
||||||
|
const { result, plan, queryClient } = await renderSwaps()
|
||||||
|
|
||||||
|
// The read finds Friday lemon orzo, field for field the day the swap
|
||||||
|
// wrote: the server has it, whatever this swap's PUT is answered with.
|
||||||
|
const swapped = { date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }
|
||||||
|
act(() => result.current.swap.mutate(ORZO))
|
||||||
|
await waitFor(() => expect(plan().days[4]).toEqual(swapped))
|
||||||
|
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||||
|
await act(() => result.current.plan.refetch())
|
||||||
|
await act(async () => friday.send(errorResponse(500, 'internal')))
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||||
|
expect(plan().days[4]).toEqual(swapped)
|
||||||
|
expect(result.current.swap.failed?.error.message).toBe('internal message')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts the day back when the swap of it fails with no plan read since, after a later swap of another day', async () => {
|
||||||
|
const friday = deferred()
|
||||||
|
const monday = deferred()
|
||||||
|
stubServer({
|
||||||
|
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||||
|
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||||
|
[`PUT ${MONDAY}`]: () => monday.reply,
|
||||||
|
})
|
||||||
|
const { result, plan, queryClient } = await renderSwaps()
|
||||||
|
|
||||||
|
act(() => result.current.swap.mutate(ORZO))
|
||||||
|
act(() => result.current.swap.mutate(FISH))
|
||||||
|
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||||
|
await act(async () => friday.send(errorResponse(500, 'internal')))
|
||||||
|
|
||||||
|
await waitFor(() => expect(plan().days[4]!.dinner.name).toBe('Dinner 4'))
|
||||||
|
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
|
||||||
|
await act(async () => monday.send(errorResponse(500, 'internal')))
|
||||||
|
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||||
|
expect(plan().days.map((day) => day.dinner.name)).toEqual(planBody().days.map((day) => day.dinner.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the plan again when the dinner is already on another day of the week, and sends the swap only once', async () => {
|
||||||
|
let read = planBody()
|
||||||
|
stubServer({
|
||||||
|
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||||
|
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
|
||||||
|
})
|
||||||
|
const { result, plan, queryClient } = await renderSwaps()
|
||||||
|
|
||||||
|
// Meanwhile lemon orzo was planned on Wednesday.
|
||||||
|
read = planBody({ 2: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||||
|
act(() => result.current.swap.mutate(ORZO))
|
||||||
|
|
||||||
|
await waitFor(() => expect(plan().days[2]!.dinner.name).toBe('Lemon orzo'))
|
||||||
|
expect(plan().days[4]!.dinner.name).toBe('Dinner 4')
|
||||||
|
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||||
|
expect(result.current.swap.failed?.error.message).toBe('dinner_already_planned message')
|
||||||
|
const sent = vi.mocked(fetch).mock.calls.map(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}`)
|
||||||
|
expect(sent.filter((call) => call === `PUT ${FRIDAY}`)).toHaveLength(1)
|
||||||
|
expect(sent.filter((call) => call === `GET ${PLAN}`)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('refuses to dismiss a pending swap', async () => {
|
it('refuses to dismiss a pending swap', async () => {
|
||||||
const friday = deferred()
|
const friday = deferred()
|
||||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
|
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
|
||||||
@@ -353,6 +430,61 @@ describe('week mutations', () => {
|
|||||||
expect(weekMutations(queryClient, LATER)).toEqual([])
|
expect(weekMutations(queryClient, LATER)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("leaves behind a week's settled swaps but for the failure that speaks for it, which stays until its week leaves it behind", async () => {
|
||||||
|
let refuse = true
|
||||||
|
stubServer({
|
||||||
|
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||||
|
[`PUT ${FRIDAY}`]: () => (refuse ? errorResponse(409, 'dinner_not_plannable') : jsonResponse(200, planBody())),
|
||||||
|
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
|
||||||
|
[`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'),
|
||||||
|
[`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'),
|
||||||
|
})
|
||||||
|
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
|
await api.loadMe()
|
||||||
|
const queryClient = createQueryClient()
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
)
|
||||||
|
const hook = renderHook(({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week) }), {
|
||||||
|
wrapper,
|
||||||
|
initialProps: { week: WEEK },
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
// Two swaps settle while the week is shown: the earlier is needed for
|
||||||
|
// ordering no longer, and only the latest stays.
|
||||||
|
act(() => hook.result.current.swap.mutate(FISH))
|
||||||
|
act(() => hook.result.current.swap.mutate(ORZO))
|
||||||
|
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap error']))
|
||||||
|
|
||||||
|
// Leaving the week, through two others, keeps the failure alone.
|
||||||
|
for (const week of [NEXT, LATER, NEXT]) {
|
||||||
|
hook.rerender({ week })
|
||||||
|
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||||
|
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coming back shows it; it fails again, and again one stays.
|
||||||
|
hook.rerender({ week: WEEK })
|
||||||
|
expect(hook.result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||||
|
act(() => hook.result.current.swap.dismiss())
|
||||||
|
act(() => hook.result.current.swap.mutate(ORZO))
|
||||||
|
await waitFor(() => expect(hook.result.current.swap.failed).not.toBeNull())
|
||||||
|
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
|
||||||
|
|
||||||
|
// A swap that succeeds is left behind once the week is.
|
||||||
|
refuse = false
|
||||||
|
act(() => hook.result.current.swap.dismiss())
|
||||||
|
act(() => hook.result.current.swap.mutate(ORZO))
|
||||||
|
await waitFor(() => expect(hook.result.current.swap.succeeded).not.toBeNull())
|
||||||
|
expect(weekMutations(queryClient, WEEK)).toEqual(['swap success'])
|
||||||
|
hook.rerender({ week: NEXT })
|
||||||
|
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||||
|
expect(weekMutations(queryClient, WEEK)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
|
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
|
||||||
const nextPush = deferred()
|
const nextPush = deferred()
|
||||||
stubServer({
|
stubServer({
|
||||||
|
|||||||
+102
-35
@@ -1,5 +1,14 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useMutation, useMutationState, useQuery, useQueryClient, type Mutation, type QueryClient } from '@tanstack/react-query'
|
import {
|
||||||
|
useMutation,
|
||||||
|
useMutationState,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
type Mutation,
|
||||||
|
type Query,
|
||||||
|
type QueryClient,
|
||||||
|
type QueryKey,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||||
@@ -83,10 +92,16 @@ function parseDay(where: string, body: unknown): PlanDay {
|
|||||||
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
|
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC3339 is an RFC 3339 instant as the server writes it: a full date and
|
||||||
|
// time, to the second or finer, and its offset, Z or ±hh:mm. A date alone,
|
||||||
|
// or a time with no offset, which Date would read in this device's zone, is
|
||||||
|
// not one.
|
||||||
|
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
||||||
|
|
||||||
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
|
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
|
||||||
function syncedAt(where: string, value: unknown): Date | null {
|
function syncedAt(where: string, value: unknown): Date | null {
|
||||||
if (value === null) return null
|
if (value === null) return null
|
||||||
const at = typeof value === 'string' ? new Date(value) : null
|
const at = typeof value === 'string' && RFC3339.test(value) ? new Date(value) : null
|
||||||
if (at === null || Number.isNaN(at.getTime())) {
|
if (at === null || Number.isNaN(at.getTime())) {
|
||||||
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
|
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
|
||||||
}
|
}
|
||||||
@@ -264,38 +279,54 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
|
|||||||
// time a week is shown, and is never removed, so a swap or push that settles
|
// time a week is shown, and is never removed, so a swap or push that settles
|
||||||
// after its Draft board, or the whole Plan, is gone is still collected.
|
// after its Draft board, or the whole Plan, is gone is still collected.
|
||||||
//
|
//
|
||||||
// When a week is shown, the settled ones of every other week are removed,
|
// A swap or push pending is kept, with every other of its kind and week:
|
||||||
// unless that week still has one pending: a pending one, and those an
|
// an earlier pending one must see those sent after it, across leaving its
|
||||||
// earlier pending one must see, stay across leaving its week and coming
|
// week and coming back. Once none of a kind and week is pending, only the
|
||||||
// back. When one settles, the same is done, except that a week's latest,
|
// latest is needed, and the rest are removed, whenever a week is shown or
|
||||||
// when it failed, stays until a week is next shown, so that coming straight
|
// one settles. The latest itself stays while its week is shown. In a week
|
||||||
// back to it shows the failure. A week's latest push whose Supply run was
|
// not shown it is removed, unless:
|
||||||
// not made stays whatever is shown: the week has no shopping list until one
|
//
|
||||||
// is made, which settles the push, or a push sent since replaces it, and
|
// - it is a failed swap: it stays, whatever is shown, until the week's
|
||||||
// making it may still be under way when the week is left. The cache holds
|
// board leaves it behind (useWeekSwap, Rebuild week, Push to Calendar),
|
||||||
// at most the weeks shown's, those of weeks left pending, one push of each
|
// so coming back to the week shows the failure;
|
||||||
// week whose Supply run was not made, and one failure of each week whose
|
// - it is a push whose Supply run was not made: the week has no shopping
|
||||||
// last pending one settled since a week was last shown.
|
// list until one is made, which settles the push, or a push sent since
|
||||||
|
// replaces it, and making it may still be under way when the week is
|
||||||
|
// left;
|
||||||
|
// - it is a failed push that settled after its week was left: it stays
|
||||||
|
// until a week is next shown, so coming straight back shows it.
|
||||||
|
//
|
||||||
|
// So once none of its kind is pending, a week keeps at most one settled
|
||||||
|
// swap and one settled push, and a week left keeps none but those above:
|
||||||
|
// settled swaps and pushes never accumulate.
|
||||||
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
|
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
|
||||||
const kept = weeksShown.get(queryClient)
|
const kept = weeksShown.get(queryClient)
|
||||||
if (kept !== undefined) return kept
|
if (kept !== undefined) return kept
|
||||||
const mutations = queryClient.getMutationCache()
|
const mutations = queryClient.getMutationCache()
|
||||||
// How many times each week is shown at once.
|
// How many times each week is shown at once.
|
||||||
const shown = new Map<string, number>()
|
const shown = new Map<string, number>()
|
||||||
|
// keep says whether the latest of its kind and week, none of which is
|
||||||
|
// pending, stays; settled says a mutation settling swept, not a week
|
||||||
|
// shown.
|
||||||
|
function keep(mutation: Mutation, of: { kind: 'swap' | 'push'; week: string }, settled: boolean): boolean {
|
||||||
|
if (shown.has(of.week)) return true
|
||||||
|
if (of.kind === 'swap') return failed(mutation)
|
||||||
|
return unmade(mutation) || (settled && failed(mutation))
|
||||||
|
}
|
||||||
function sweep(settled: boolean) {
|
function sweep(settled: boolean) {
|
||||||
// The mutations of each kind of each week not shown, in the order sent.
|
// The mutations of each kind of each week, in the order sent.
|
||||||
const left = new Map<string, Mutation[]>()
|
const sent = new Map<string, { of: { kind: 'swap' | 'push'; week: string }; week: Mutation[] }>()
|
||||||
for (const mutation of mutations.getAll()) {
|
for (const mutation of mutations.getAll()) {
|
||||||
const of = weekMutation(mutation)
|
const of = weekMutation(mutation)
|
||||||
if (of === null || shown.has(of.week)) continue
|
if (of === null) continue
|
||||||
const id = `${of.kind} ${of.week}`
|
const id = `${of.kind} ${of.week}`
|
||||||
left.set(id, [...(left.get(id) ?? []), mutation])
|
sent.set(id, { of, week: [...(sent.get(id)?.week ?? []), mutation] })
|
||||||
}
|
}
|
||||||
for (const week of left.values()) {
|
for (const { of, week } of sent.values()) {
|
||||||
if (week.some((mutation) => mutation.state.status === 'pending')) continue
|
if (week.some((mutation) => mutation.state.status === 'pending')) continue
|
||||||
const latest = week.at(-1)
|
const latest = week.at(-1)
|
||||||
for (const mutation of week) {
|
for (const mutation of week) {
|
||||||
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
|
if (mutation === latest && keep(mutation, of, settled)) continue
|
||||||
mutations.remove(mutation)
|
mutations.remove(mutation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,6 +355,30 @@ function useWeekShown(weekStart: string) {
|
|||||||
useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart])
|
useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swapWrites counts, for each plan query, its updates that were a swap's
|
||||||
|
// own optimistic write or rollback, not a plan read from the server.
|
||||||
|
const swapWrites = new WeakMap<Query, number>()
|
||||||
|
|
||||||
|
// serverReads is how many plans read from the server have landed in the
|
||||||
|
// plan query: its updates, less the swaps' own writes. A plan read lands
|
||||||
|
// whether or not it differs from the plan shown.
|
||||||
|
function serverReads(query: Query): number {
|
||||||
|
return query.state.dataUpdateCount - (swapWrites.get(query) ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSwap updates the plan query as a swap's own write, which serverReads
|
||||||
|
// does not count.
|
||||||
|
function writeSwap(queryClient: QueryClient, query: Query, update: (plan: Plan | null | undefined) => Plan | null | undefined) {
|
||||||
|
const before = query.state.dataUpdateCount
|
||||||
|
queryClient.setQueryData<Plan | null>(query.queryKey, update)
|
||||||
|
swapWrites.set(query, (swapWrites.get(query) ?? 0) + query.state.dataUpdateCount - before)
|
||||||
|
}
|
||||||
|
|
||||||
|
// planQuery is the plan query keyed key, if the query cache has it.
|
||||||
|
function planQuery(queryClient: QueryClient, key: QueryKey): Query | undefined {
|
||||||
|
return queryClient.getQueryCache().find({ queryKey: key, exact: true })
|
||||||
|
}
|
||||||
|
|
||||||
// swapKey keys the swaps of the week starting on weekStart in the mutation
|
// swapKey keys the swaps of the week starting on weekStart in the mutation
|
||||||
// cache, where they outlive the Draft board that sent them.
|
// cache, where they outlive the Draft board that sent them.
|
||||||
function swapKey(weekStart: string) {
|
function swapKey(weekStart: string) {
|
||||||
@@ -345,9 +400,13 @@ export type SwapDay = {
|
|||||||
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
|
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
|
||||||
// once, NEW when it came from the Quest log, with no start-by time until the
|
// once, NEW when it came from the Quest log, with no start-by time until the
|
||||||
// server answers with the plan as swapped, which then replaces it. A refused
|
// server answers with the plan as swapped, which then replaces it. A refused
|
||||||
// swap puts the day back as it was, unless it has changed again meanwhile.
|
// swap puts the day back as it was, unless it has changed again meanwhile:
|
||||||
// However the swap ends, the Quest log is read again: the server leaves a
|
// another swap wrote over it, or any plan read from the server landed
|
||||||
// dinner on the plan out of it.
|
// since, even one with the very day the swap wrote. A swap refused because
|
||||||
|
// the dinner is already on another day of the week (409
|
||||||
|
// dinner_already_planned) reads the plan again, so the household chooses
|
||||||
|
// again from the plan as it is. However the swap ends, the Quest log is
|
||||||
|
// read again: the server leaves a dinner on the plan out of it.
|
||||||
//
|
//
|
||||||
// The week's swaps live in the mutation cache, not in the component, so
|
// The week's swaps live in the mutation cache, not in the component, so
|
||||||
// leaving the week and coming back keeps them: pending and failed are read
|
// leaving the week and coming back keeps them: pending and failed are read
|
||||||
@@ -374,7 +433,7 @@ export function useSwapDay(weekStart: string): SwapDay {
|
|||||||
function mutate(chosen: Swap) {
|
function mutate(chosen: Swap) {
|
||||||
// isLatest says whether no swap of the week was sent after this one.
|
// isLatest says whether no swap of the week was sent after this one.
|
||||||
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
|
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
|
||||||
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay; query: Query; reads: number }>(queryClient, {
|
||||||
mutationKey,
|
mutationKey,
|
||||||
// Kept until dismissed or settled in a week not shown
|
// Kept until dismissed or settled in a week not shown
|
||||||
// (keepWeekMutations): a later swap must stay in the cache while an
|
// (keepWeekMutations): a later swap must stay in the cache while an
|
||||||
@@ -387,24 +446,32 @@ export function useSwapDay(weekStart: string): SwapDay {
|
|||||||
},
|
},
|
||||||
onMutate: async ({ date, dinner, fromQuestLog }) => {
|
onMutate: async ({ date, dinner, fromQuestLog }) => {
|
||||||
await queryClient.cancelQueries({ queryKey: key })
|
await queryClient.cancelQueries({ queryKey: key })
|
||||||
const plan = queryClient.getQueryData<Plan | null>(key)
|
const query = planQuery(queryClient, key)
|
||||||
|
const plan = query?.state.data as Plan | null | undefined
|
||||||
const previous = plan?.days.find((day) => day.date === date)
|
const previous = plan?.days.find((day) => day.date === date)
|
||||||
if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
|
if (query === undefined || !plan || previous === undefined) {
|
||||||
|
throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
|
||||||
|
}
|
||||||
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
|
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
|
||||||
queryClient.setQueryData<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) })
|
writeSwap(queryClient, query, () => ({ ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }))
|
||||||
return { previous, swapped }
|
return { previous, swapped, query, reads: serverReads(query) }
|
||||||
},
|
},
|
||||||
onSuccess: (plan) => {
|
onSuccess: (plan) => {
|
||||||
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
|
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
|
||||||
},
|
},
|
||||||
// The day goes back only while it is still, field for field, the day
|
// The day goes back only while it is still, field for field, the day
|
||||||
// this swap wrote: a later swap or plan read that replaced it is left
|
// this swap wrote, and no plan read from the server has landed since
|
||||||
// alone.
|
// (serverReads): a later swap that replaced it, or a read that did,
|
||||||
onError: (_error, _swap, context) => {
|
// even with the same day, is left alone.
|
||||||
if (context === undefined) return
|
onError: (error, _swap, context) => {
|
||||||
queryClient.setQueryData<Plan | null>(key, (plan) =>
|
if (context !== undefined && planQuery(queryClient, key) === context.query && serverReads(context.query) === context.reads) {
|
||||||
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
writeSwap(queryClient, context.query, (plan) =>
|
||||||
)
|
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError && error.code === 'dinner_already_planned') {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: key })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ export function RegularsSettingsPage() {
|
|||||||
// Which sheet is open, for which Regular.
|
// Which sheet is open, for which Regular.
|
||||||
type Open = { sheet: 'edit' | 'fix'; id: string } | null
|
type Open = { sheet: 'edit' | 'fix'; id: string } | null
|
||||||
|
|
||||||
function RegularsManager() {
|
// RegularsManager lists, adds, edits and fixes the recipes of the
|
||||||
|
// household's Regulars: this page's body, and the Settings "Regular
|
||||||
|
// dinners" sub-tab at tablet and desktop widths.
|
||||||
|
export function RegularsManager() {
|
||||||
const regulars = useRegulars()
|
const regulars = useRegulars()
|
||||||
const add = useAddRegular()
|
const add = useAddRegular()
|
||||||
const remove = useRemoveRegular()
|
const remove = useRemoveRegular()
|
||||||
|
|||||||
@@ -104,7 +104,10 @@ describe('SettingsPage', () => {
|
|||||||
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||||
expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
||||||
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||||
|
// The rotation count and the hidden Regular dinners sub-tab both read
|
||||||
|
// the Regulars.
|
||||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||||
|
REGULARS_PATH,
|
||||||
REGULARS_PATH,
|
REGULARS_PATH,
|
||||||
CALENDAR_PATH,
|
CALENDAR_PATH,
|
||||||
'/api/v1/settings/ai',
|
'/api/v1/settings/ai',
|
||||||
@@ -117,7 +120,7 @@ describe('SettingsPage', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows the not-yet-available states while the server has no endpoint for them', async () => {
|
it('shows the not-yet-available states while the server has no endpoint for them', async () => {
|
||||||
stubSettings()
|
stubSettings({ [REGULARS_PATH]: () => errorResponse(404, 'not_found') })
|
||||||
renderSettings()
|
renderSettings()
|
||||||
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
|
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -101,24 +101,3 @@
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardTitle {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--color-neutral-700);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
HOUSEHOLD_SETTINGS,
|
HOUSEHOLD_SETTINGS,
|
||||||
NOTIFICATION_SETTINGS,
|
NOTIFICATION_SETTINGS,
|
||||||
RECIPE_SEARCH_SETTINGS,
|
RECIPE_SEARCH_SETTINGS,
|
||||||
|
SETTINGS_REGULARS,
|
||||||
} from '../test/ApiProviders.tsx'
|
} from '../test/ApiProviders.tsx'
|
||||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
|
||||||
|
import { fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
import { SettingsPage } from './SettingsPage.tsx'
|
import { SettingsPage } from './SettingsPage.tsx'
|
||||||
@@ -32,6 +34,7 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
|
|||||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||||
'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
||||||
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
|
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
|
||||||
|
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
|
||||||
...responders,
|
...responders,
|
||||||
}
|
}
|
||||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||||
@@ -121,7 +124,7 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
|
|||||||
expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true')
|
expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true')
|
||||||
expect(tab('Regular dinners')).toHaveFocus()
|
expect(tab('Regular dinners')).toHaveFocus()
|
||||||
expect(panel('Regular dinners')).toBeVisible()
|
expect(panel('Regular dinners')).toBeVisible()
|
||||||
expect(within(panel('Regular dinners')).getByRole('region', { name: 'Regular dinners' })).toBeInTheDocument()
|
expect(await within(panel('Regular dinners')).findByRole('heading', { name: 'On rotation · 1' })).toBeInTheDocument()
|
||||||
expect(panel('Household')).not.toBeVisible()
|
expect(panel('Household')).not.toBeVisible()
|
||||||
|
|
||||||
fireEvent.keyDown(tab('Regular dinners'), { key: 'End' })
|
fireEvent.keyDown(tab('Regular dinners'), { key: 'End' })
|
||||||
@@ -165,6 +168,123 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const TACOS = dinnerBody('d1', 'Beef tacos', { method: 'stovetop', minutes_total: 40, difficulty: 'easy' })
|
||||||
|
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
|
||||||
|
|
||||||
|
// stubRegulars answers the Regulars from server.regulars, so a write's
|
||||||
|
// responder changes what the next read sees.
|
||||||
|
function stubRegulars(regulars: DinnerBody[], responders: (server: { regulars: DinnerBody[] }) => Record<string, Responder>) {
|
||||||
|
const server = { regulars }
|
||||||
|
const fetchMock = stubAPI({
|
||||||
|
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: server.regulars }),
|
||||||
|
...responders(server),
|
||||||
|
})
|
||||||
|
return { server, fetchMock }
|
||||||
|
}
|
||||||
|
|
||||||
|
// writes lists the requests other than GETs, as method, path and body.
|
||||||
|
function writes(fetchMock: Mock<typeof fetch>) {
|
||||||
|
return fetchMock.mock.calls
|
||||||
|
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
|
||||||
|
.map(([input, init]) => [init?.method, String(input), init?.body])
|
||||||
|
}
|
||||||
|
|
||||||
|
// openRegulars shows the Regular dinners tab once its Regulars have loaded.
|
||||||
|
async function openRegulars() {
|
||||||
|
fireEvent.click(tab('Regular dinners'))
|
||||||
|
const regulars = within(panel('Regular dinners'))
|
||||||
|
await regulars.findByRole('heading', { level: 2, name: /^On rotation/ })
|
||||||
|
return regulars
|
||||||
|
}
|
||||||
|
|
||||||
|
function regularRow(name: string) {
|
||||||
|
const item = within(screen.getByRole('list', { name: 'Regulars' }))
|
||||||
|
.getAllByRole('listitem')
|
||||||
|
.find((li) => within(li).queryByText(name) !== null)
|
||||||
|
if (item === undefined) throw new Error(`no Regular row named ${name}`)
|
||||||
|
return within(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.each([1440, 1180])('SettingsPage Regular dinners tab at %i px (mock 2e)', (width) => {
|
||||||
|
it('lists the Regulars as /settings/regulars does, under the page title rather than its own', async () => {
|
||||||
|
stubRegulars([TACOS, PIZZA], () => ({}))
|
||||||
|
await renderAt(width)
|
||||||
|
const regulars = await openRegulars()
|
||||||
|
expect(regulars.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
||||||
|
expect(regularRow('Beef tacos').getByText('Stovetop · 40 min')).toBeInTheDocument()
|
||||||
|
expect(regularRow('Beef tacos').getByText('Recipe found')).toBeInTheDocument()
|
||||||
|
expect(regularRow('Pizza night').getByText('Needs a recipe')).toBeInTheDocument()
|
||||||
|
expect(regulars.getByRole('textbox', { name: 'Add a Regular' })).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByRole('heading', { level: 1 }).map((h) => h.textContent)).toEqual(['Settings'])
|
||||||
|
expect(regulars.queryByRole('button', { name: 'Back to Settings' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds a Regular by name', async () => {
|
||||||
|
const { fetchMock } = stubRegulars([TACOS], (server) => ({
|
||||||
|
'POST /api/v1/dinners': (body) => {
|
||||||
|
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
||||||
|
server.regulars = [...server.regulars, created]
|
||||||
|
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
await renderAt(width)
|
||||||
|
const regulars = await openRegulars()
|
||||||
|
fireEvent.change(regulars.getByRole('textbox', { name: 'Add a Regular' }), { target: { value: 'Chicken enchiladas' } })
|
||||||
|
fireEvent.click(regulars.getByRole('button', { name: 'Add Regular' }))
|
||||||
|
|
||||||
|
expect(await regulars.findByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
||||||
|
expect(regularRow('Chicken enchiladas').getByText('Finding recipe…')).toBeInTheDocument()
|
||||||
|
expect(writes(fetchMock)).toEqual([['POST', '/api/v1/dinners', JSON.stringify({ name: 'Chicken enchiladas' })]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edits a Regular in its sheet', async () => {
|
||||||
|
const { fetchMock } = stubRegulars([TACOS], (server) => ({
|
||||||
|
'PATCH /api/v1/dinners/d1': (body) => {
|
||||||
|
const edited = { ...TACOS, ...(body as object) }
|
||||||
|
server.regulars = [edited]
|
||||||
|
return jsonResponse(200, edited)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
await renderAt(width)
|
||||||
|
await openRegulars()
|
||||||
|
fireEvent.click(regularRow('Beef tacos').getByRole('button', { name: /^Beef tacos/ }))
|
||||||
|
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
||||||
|
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } })
|
||||||
|
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
expect(writes(fetchMock)).toEqual([['PATCH', '/api/v1/dinners/d1', JSON.stringify({ minutes_total: 25 })]])
|
||||||
|
expect(await regularRow('Beef tacos').findByText('Stovetop · 25 min')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fixes the recipe of a Regular that needs one', async () => {
|
||||||
|
const { fetchMock } = stubRegulars([PIZZA], (server) => ({
|
||||||
|
'GET /api/v1/dinners/d3/recipe-candidates': () =>
|
||||||
|
jsonResponse(200, {
|
||||||
|
candidates: [
|
||||||
|
{ candidate_id: 'https://example.com/pizza', title: 'Weeknight pizza', site_name: 'Example Kitchen', photo_url: null, minutes: 45 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'POST /api/v1/dinners/d3/ground': () => {
|
||||||
|
const fixed = { ...PIZZA, grounding_status: 'grounded', grounding_error: null }
|
||||||
|
server.regulars = [fixed]
|
||||||
|
return jsonResponse(200, fixed)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
await renderAt(width)
|
||||||
|
await openRegulars()
|
||||||
|
fireEvent.click(regularRow('Pizza night').getByRole('button', { name: /^Pizza night/ }))
|
||||||
|
const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
||||||
|
fireEvent.click(await sheet.findByRole('button', { name: 'Use Weeknight pizza from Example Kitchen' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
expect(writes(fetchMock)).toEqual([
|
||||||
|
['POST', '/api/v1/dinners/d3/ground', JSON.stringify({ candidate_id: 'https://example.com/pizza' })],
|
||||||
|
])
|
||||||
|
expect(await regularRow('Pizza night').findByText('Recipe found')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('SettingsPage reminder time across tabs', () => {
|
describe('SettingsPage reminder time across tabs', () => {
|
||||||
it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => {
|
it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => {
|
||||||
const saved = { ...HOUSEHOLD_SETTINGS }
|
const saved = { ...HOUSEHOLD_SETTINGS }
|
||||||
@@ -195,10 +315,7 @@ describe('SettingsPage reminder time across tabs', () => {
|
|||||||
|
|
||||||
describe('SettingsPage across the 768 px breakpoint', () => {
|
describe('SettingsPage across the 768 px breakpoint', () => {
|
||||||
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
||||||
stubAPI({
|
stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
|
||||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
|
||||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
|
||||||
})
|
|
||||||
const resize = await renderAt(767)
|
const resize = await renderAt(767)
|
||||||
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
|
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
|
||||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
||||||
@@ -209,10 +326,7 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
|
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
|
||||||
stubAPI({
|
stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
|
||||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
|
||||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
|
||||||
})
|
|
||||||
const resize = await renderAt(1180)
|
const resize = await renderAt(1180)
|
||||||
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
|
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
|
||||||
target: { value: 'A draft' },
|
target: { value: 'A draft' },
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||||
import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts'
|
import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts'
|
||||||
import { Card } from '../ui/Card.tsx'
|
|
||||||
import { AIAssistantCard } from './AISettingsPage.tsx'
|
import { AIAssistantCard } from './AISettingsPage.tsx'
|
||||||
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
|
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
|
||||||
import { LeaveGuard } from './LeaveGuard.tsx'
|
import { LeaveGuard } from './LeaveGuard.tsx'
|
||||||
import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx'
|
import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx'
|
||||||
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
||||||
|
import { RegularsManager } from './RegularsSettingsPage.tsx'
|
||||||
import styles from './SettingsTabs.module.css'
|
import styles from './SettingsTabs.module.css'
|
||||||
|
|
||||||
type Tab = 'household' | 'regulars' | 'notifications-ai'
|
type Tab = 'household' | 'regulars' | 'notifications-ai'
|
||||||
@@ -101,13 +101,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
|||||||
|
|
||||||
{panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)}
|
{panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)}
|
||||||
|
|
||||||
{panel(
|
{panel('regulars', styles.single, <RegularsManager />)}
|
||||||
'regulars',
|
|
||||||
styles.single,
|
|
||||||
// TODO(Feature 12): embed the Regular dinners screen here once Feature
|
|
||||||
// 12 builds it.
|
|
||||||
<Placeholder title="Regular dinners">Managing the household's Regulars arrives in a later update.</Placeholder>,
|
|
||||||
)}
|
|
||||||
|
|
||||||
{panel(
|
{panel(
|
||||||
'notifications-ai',
|
'notifications-ai',
|
||||||
@@ -128,17 +122,3 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder is a neutral card holding a section's place until the feature
|
|
||||||
// that owns it renders it.
|
|
||||||
function Placeholder({ title, children }: { title: string; children: ReactNode }) {
|
|
||||||
const titleId = useId()
|
|
||||||
return (
|
|
||||||
<Card className={styles.placeholder} aria-labelledby={titleId} role="region">
|
|
||||||
<h2 id={titleId} className={styles.cardTitle}>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.hint}>{children}</p>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
|
import { dinnerBody } from './dinners.ts'
|
||||||
|
import { fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||||
|
|
||||||
// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
|
// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
|
||||||
export const AI_SETTINGS = {
|
export const AI_SETTINGS = {
|
||||||
@@ -99,12 +100,16 @@ export const CHAT_WEBHOOK = {
|
|||||||
last_test_status: null,
|
last_test_status: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SETTINGS_REGULARS is a GET /api/v1/dinners?status=regular body: one
|
||||||
|
// Regular, its recipe found.
|
||||||
|
export const SETTINGS_REGULARS = { dinners: [dinnerBody('r1', 'Beef tacos')] }
|
||||||
|
|
||||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||||
// today's server does: the dinners endpoint does not exist yet, and neither
|
// today's server does: one Regular on rotation, and neither Google Calendar
|
||||||
// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so
|
// nor Google Chat connected. The home's sub-tabs stay mounted on mobile, so
|
||||||
// it also reads the AI models and recipe search settings.
|
// it also reads the Regulars list, the AI models and recipe search settings.
|
||||||
export const SETTINGS_API = {
|
export const SETTINGS_API = {
|
||||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'/api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
|
||||||
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||||
'/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
'/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
||||||
'/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),
|
'/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),
|
||||||
|
|||||||
Reference in New Issue
Block a user