Feature 14 integration fix: revalidate the draft pool, refuse duplicate week dinners, refresh the Quest log (#122)
CI / ci (push) Successful in 3m43s
CI / ci (pull_request) Successful in 3m55s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #122.
This commit is contained in:
2026-10-07 16:30:03 -04:00
committed by approver_agent
parent 955613941f
commit 76a2b611c8
8 changed files with 168 additions and 22 deletions
+8 -2
View File
@@ -357,13 +357,19 @@ automatic** (D-021).
left as it is and the job fails `confirm_required`. The job reads the left as it is and the job fails `confirm_required`. The job reads the
plan's revision before draft_week runs; if the plan was edited (a swap, plan's revision before draft_week runs; if the plan was edited (a swap,
or another draft stored) by the time the draft is stored, the plan is or another draft stored) by the time the draft is stored, the plan is
left as it is and the job fails `plan_changed`. A pool under seven left as it is and the job fails `plan_changed`. The pool is read again
in the same transaction that stores the draft; if a dinner the draft
planned has left it meanwhile (archived, `tried`, or a Quest log dinner
stored on another current or later week's plan, as by a draft of
another week), nothing is stored and the job fails `pool_changed`,
which drafting again resolves. A pool under seven
dinners fails the job `not_enough_dinners`, draft_week failing dinners fails the job `not_enough_dinners`, draft_week failing
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes `ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
`{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes `{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes
`is_new`, moves a `synced` plan to `changed`, and answers 200 with the `is_new`, moves a `synced` plan to `changed`, and answers 200 with the
plan. A date outside the week is 400 `invalid_date`, an unknown dinner or plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
a week with no plan 404, and any other dinner 409 `dinner_not_plannable`. a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
a dinner already on another day of the week 409 `dinner_already_planned`.
- Rotation check (`plan.Service`, D-021). `MarkTried(date)`, which the - Rotation check (`plan.Service`, D-021). `MarkTried(date)`, which the
`rotation_check` schedule calls for the household-local day just past, `rotation_check` schedule calls for the household-local day just past,
moves every `quest_log` dinner planned on that date, on a plan of any moves every `quest_log` dinner planned on that date, on a plan of any
+23 -6
View File
@@ -36,9 +36,9 @@ func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// Regulars and the Quest log dinners no other current or later plan has, // Regulars and the Quest log dinners no other current or later plan has,
// given the household context and last week's dinners, and the draft is // given the household context and last week's dinners, and the draft is
// stored as the week's plan if the plan is still as it was before // stored as the week's plan if the plan is still as it was before
// draft_week ran. A pool under seven dinners fails with // draft_week ran and every dinner it planned is still in the pool. A pool
// ReasonNotEnoughDinners and draft_week failing with ReasonAIFailed; no // under seven dinners fails with ReasonNotEnoughDinners and draft_week
// week is made up instead. // failing with ReasonAIFailed; no week is made up instead.
func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) { func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) {
weekStart, err := ParseWeekStart(p.WeekStart) weekStart, err := ParseWeekStart(p.WeekStart)
if err != nil { if err != nil {
@@ -68,7 +68,7 @@ func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult)
for _, w := range week.Warnings { for _, w := range week.Warnings {
s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w) s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w)
} }
planID, err := s.storeDraft(ctx, weekStart, p.Confirm, base, week) planID, err := s.storeDraft(ctx, weekStart, household.WeekStart(in.Context.Today), p.Confirm, base, week)
if err != nil { if err != nil {
return draftResult{}, err return draftResult{}, err
} }
@@ -142,11 +142,28 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill
// draft was asked for without confirm is left as it is, failing with // draft was asked for without confirm is left as it is, failing with
// ReasonConfirmRequired, and so is a plan no longer at revision base (the // ReasonConfirmRequired, and so is a plan no longer at revision base (the
// revision before draft_week ran, or noPlan), failing with // revision before draft_week ran, or noPlan), failing with
// ReasonPlanChanged. // ReasonPlanChanged. The pool is read again, as of thisWeek like the pool
func (s *Service) storeDraft(ctx context.Context, weekStart store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) { // draft_week was given: a day whose dinner is no longer in it, or is in it
// as a Regular rather than the Quest log or the other way round, leaves
// everything as it is, failing with ReasonPoolChanged.
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
var planID string var planID string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error { err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx) q := db.New(tx)
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
if err != nil {
return fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make(map[string]string, len(rows))
for _, d := range rows {
pool[d.ID] = d.Status
}
for _, d := range week.Days {
status, ok := pool[d.DinnerID]
if !ok || (status == dinners.StatusQuestLog) != d.IsNew {
return &jobs.Failure{Code: ReasonPoolChanged}
}
}
now := store.Now().String() now := store.Now().String()
note := sql.NullString{String: week.Note, Valid: week.Note != ""} note := sql.NullString{String: week.Note, Valid: week.Note != ""}
cur, err := q.GetWeekPlan(ctx, weekStart.String()) cur, err := q.GetWeekPlan(ctx, weekStart.String())
+21 -4
View File
@@ -53,10 +53,15 @@ const (
// ReasonPlanChanged: the plan was edited while draft_week ran, so the // ReasonPlanChanged: the plan was edited while draft_week ran, so the
// draft does not replace the edit. // draft does not replace the edit.
ReasonPlanChanged = "plan_changed" ReasonPlanChanged = "plan_changed"
// ReasonPoolChanged: a dinner the draft planned left the pool while
// draft_week ran (archived, tried, or its Quest log dinner planned on
// another week), so the draft is not stored; drafting again uses the
// pool as it is now.
ReasonPoolChanged = "pool_changed"
) )
// Reasons are every draft failure reason. // Reasons are every draft failure reason.
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged} var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged, ReasonPoolChanged}
// ContextBudget is the character budget of the household context // ContextBudget is the character budget of the household context
// draft_week is given (see harness.BuildContext). // draft_week is given (see harness.BuildContext).
@@ -71,6 +76,10 @@ var ErrNotFound = errors.New("plan not found")
// ErrDinnerNotFound is a swap to an unknown dinner. // ErrDinnerNotFound is a swap to an unknown dinner.
var ErrDinnerNotFound = errors.New("dinner not found") var ErrDinnerNotFound = errors.New("dinner not found")
// ErrDinnerAlreadyPlanned is a swap to a dinner already on another day of
// the week.
var ErrDinnerAlreadyPlanned = errors.New("the dinner is already on another day of this week")
// ErrConfirmRequired is a draft over a synced or changed plan asked for // ErrConfirmRequired is a draft over a synced or changed plan asked for
// without confirm. // without confirm.
var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm") var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm")
@@ -337,9 +346,10 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
// Swap plans dinnerID on date of the week starting weekStart, marking the // Swap plans dinnerID on date of the week starting weekStart, marking the
// day new when the dinner is in the Quest log. Swapping a synced plan // day new when the dinner is in the Quest log. Swapping a synced plan
// makes it changed. The week must have a plan (else ErrNotFound), date // makes it changed. The week must have a plan (else ErrNotFound), date
// must be one of its days (else *InvalidDayError), and the dinner must be // must be one of its days (else *InvalidDayError), the dinner must be a
// a Regular or in the Quest log (else ErrDinnerNotFound or // Regular or in the Quest log (else ErrDinnerNotFound or
// *DinnerNotPlannableError). It returns the plan as swapped. // *DinnerNotPlannableError), and it must not be on another day of the
// week (else ErrDinnerAlreadyPlanned). It returns the plan as swapped.
func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) { func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) {
dates, err := household.WeekDays(weekStart) dates, err := household.WeekDays(weekStart)
if err != nil { if err != nil {
@@ -369,6 +379,13 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
if !slices.Contains(plannable, d.Status) { if !slices.Contains(plannable, d.Status) {
return &DinnerNotPlannableError{Status: d.Status} return &DinnerNotPlannableError{Status: d.Status}
} }
days, err := q.ListPlanDays(ctx, cur.ID)
if err != nil {
return fmt.Errorf("plan: read days of %s: %w", cur.ID, err)
}
if slices.ContainsFunc(days, func(r db.ListPlanDaysRow) bool { return r.Date != day.String() && r.Dinner.ID == d.ID }) {
return ErrDinnerAlreadyPlanned
}
now := store.Now().String() now := store.Now().String()
n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{ n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{
DinnerID: sql.NullString{String: d.ID, Valid: true}, DinnerID: sql.NullString{String: d.ID, Valid: true},
+81 -7
View File
@@ -174,9 +174,15 @@ func (e *env) reply(note string, names ...string) {
// weekReply is the draft_week reply planning the dinners named names on // weekReply is the draft_week reply planning the dinners named names on
// weekDates, in order. // weekDates, in order.
func (e *env) weekReply(note string, names ...string) llm.GenerateResult { func (e *env) weekReply(note string, names ...string) llm.GenerateResult {
return e.weekReplyOn(weekDates, note, names...)
}
// weekReplyOn is the draft_week reply planning the dinners named names on
// dates, in order.
func (e *env) weekReplyOn(dates []string, note string, names ...string) llm.GenerateResult {
var days []string var days []string
for i, name := range names { for i, name := range names {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, weekDates[i], e.ids[name])) days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], e.ids[name]))
} }
js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note) js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}} return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
@@ -398,15 +404,16 @@ func TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) {
t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want) t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want)
} }
} }
// Swapped to Beef Chili, Sunday starts at 13:30 EST. // Swapped to Short Ribs (240 minutes), Sunday starts at 13:30 EST.
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", e.ids["Beef Chili"]); err != nil { ribs := e.dinner(t, "Short Ribs", "regular", "crockpot", 240)
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", ribs); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if p, err = e.svc.Get(t.Context(), dst); err != nil { if p, err = e.svc.Get(t.Context(), dst); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` { if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` {
t.Errorf("Sunday Beef Chili start_by = %s, %v; want 13:30 EST", b, err) t.Errorf("Sunday Short Ribs start_by = %s, %v; want 13:30 EST", b, err)
} }
} }
@@ -466,6 +473,70 @@ func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) {
} }
} }
func TestDraftFailsWhenPoolDinnerLeavesWhileDrafting(t *testing.T) {
for _, status := range []string{"archived", "tried"} {
t.Run(status, func(t *testing.T) {
e := newEnv(t)
e.start(t)
// Pad Thai leaves the Quest log while draft_week plans it.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = ? WHERE id = ?`, status, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
})
}
}
func TestDraftsOfTwoWeeksDoNotShareAQuestLogDinner(t *testing.T) {
e := newEnv(t)
e.start(t)
// This week's draft plans Pad Thai; while draft_week runs, the other
// worker drafts a later week with Pad Thai too and stores it first.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
later := mustDate("2099-01-05")
laterDates := []string{"2099-01-05", "2099-01-06", "2099-01-07", "2099-01-08", "2099-01-09", "2099-01-10", "2099-01-11"}
e.model.QueueGenerate(e.weekReplyOn(laterDates, "", fullWeek...))
other, err := e.svc.StartDraft(t.Context(), later, false)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, other.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("later week's draft %s %v", job.Status, job.Error)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
}
func TestDraftFailsWithNotEnoughDinners(t *testing.T) { func TestDraftFailsWithNotEnoughDinners(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.start(t) e.start(t)
@@ -633,7 +704,8 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
<-started <-started
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Sheet Pan Salmon"]); err != nil { e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil {
t.Fatal(err) t.Fatal(err)
} }
close(release) close(release)
@@ -646,7 +718,7 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
want := slices.Clone(fullWeek) want := slices.Clone(fullWeek)
want[2] = "Sheet Pan Salmon" want[2] = "Fish Tacos"
if *p.AINote != "first" || !slices.Equal(dayNames(p), want) { if *p.AINote != "first" || !slices.Equal(dayNames(p), want) {
t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want) t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want)
} }
@@ -822,7 +894,8 @@ func TestSwapStatusTransitions(t *testing.T) {
e.reply("", fullWeek...) e.reply("", fullWeek...)
e.mustDraft(t, false) e.mustDraft(t, false)
e.setStatus(t, tc.from) e.setStatus(t, tc.from)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", e.ids["Pad Thai"]) fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", fishTacos)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -852,6 +925,7 @@ func TestSwapRefusals(t *testing.T) {
}{ }{
{"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }}, {"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }},
{"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }}, {"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }},
{"dinner on another day", week, "2026-10-12", e.ids["Pot Roast"], func(err error) bool { return errors.Is(err, plan.ErrDinnerAlreadyPlanned) }},
{"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, &notPlannable) }}, {"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, &notPlannable) }},
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, &notPlannable) }}, {"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, &notPlannable) }},
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }}, {"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
+4 -2
View File
@@ -107,8 +107,8 @@ func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger
// writePlanError maps a plan.Service error to its API error: a week with // writePlanError maps a plan.Service error to its API error: a week with
// no plan or an unknown dinner is 404, a date outside the week is 400, and // no plan or an unknown dinner is 404, a date outside the week is 400, and
// drafting over a synced plan without confirm, a confirmed draft while an // drafting over a synced plan without confirm, a confirmed draft while an
// unconfirmed one runs, or planning a dinner that is neither a Regular nor // unconfirmed one runs, planning a dinner that is neither a Regular nor in
// in the Quest log, is 409. // the Quest log, or one already on another day of the week, is 409.
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var day *plan.InvalidDayError var day *plan.InvalidDayError
var week *plan.InvalidWeekError var week *plan.InvalidWeekError
@@ -120,6 +120,8 @@ func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger,
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error()) writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error())
case errors.As(err, &notPlannable): case errors.As(err, &notPlannable):
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error()) writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
case errors.Is(err, plan.ErrDinnerAlreadyPlanned):
writeError(w, r, logger, http.StatusConflict, "dinner_already_planned", err.Error())
case errors.Is(err, plan.ErrConfirmRequired): case errors.Is(err, plan.ErrConfirmRequired):
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error()) writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
case errors.Is(err, plan.ErrDraftRunning): case errors.Is(err, plan.ErrDraftRunning):
+1
View File
@@ -309,6 +309,7 @@ func TestPutPlanDay(t *testing.T) {
{"2026-10-12/days/2026-10-32", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"}, {"2026-10-12/days/2026-10-32", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"}, {"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"},
{"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"}, {"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"},
{"2026-10-12/days/2026-10-15", `{"dinner_id":"` + e.questLog + `"}`, http.StatusConflict, "dinner_already_planned"},
} { } {
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body) rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body)
if rec.Code != tc.status || errorCode(body) != tc.code { if rec.Code != tc.status || errorCode(body) != tc.code {
+25
View File
@@ -972,6 +972,31 @@ describe.each([
expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])) await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]))
}) })
it('reads the Quest log again once a draft finishes, dropping the dinners it planned', async () => {
let plan = planBody('draft')
let job = draftJob('running')
let questLog = [dinner('q1', 'Lemon orzo', 'easy', 25)]
stubServer({
...lists,
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
[`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog }),
})
await renderPage()
const list = await screen.findByRole('list', { name: layout === 'desktop' ? 'Quest log' : 'Quest log dinners' })
expect(await within(list).findByText('Lemon orzo')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' }))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
plan = planBody('draft', ['Lemon orzo', ...NAMES.slice(1)])
questLog = []
job = draftJob('succeeded')
const week = await screen.findByRole('list', { name: 'Dinners this week' }, { timeout: 3000 })
expect(await within(week).findByText('Lemon orzo', {}, { timeout: 3000 })).toBeInTheDocument()
await waitFor(() => expect(within(list).queryByText('Lemon orzo')).not.toBeInTheDocument())
})
}) })
// Dragging a dinner from desktop's panel or tablet's strip onto a day swaps // Dragging a dinner from desktop's panel or tablet's strip onto a day swaps
+5 -1
View File
@@ -2,6 +2,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react' import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { useJob } from '../api/index.ts' import { useJob } from '../api/index.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx' import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx' import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
@@ -57,6 +58,7 @@ const draftFailures: Record<string, string> = {
ai_failed: "The AI assistant couldn't draft it.", ai_failed: "The AI assistant couldn't draft it.",
confirm_required: 'The plan was pushed to Calendar while it was drafting.', confirm_required: 'The plan was pushed to Calendar while it was drafting.',
plan_changed: 'The plan was edited while it was drafting.', plan_changed: 'The plan was edited while it was drafting.',
pool_changed: 'A dinner it picked was archived, tried or planned for another week while it was drafting.',
internal_error: 'Something went wrong on the server.', internal_error: 'Something went wrong on the server.',
} }
@@ -239,12 +241,14 @@ function WeekBoard({
// Once the job ends the plan is read again: a succeeded draft is followed // Once the job ends the plan is read again: a succeeded draft is followed
// until that read lands; a failed one stays shown, and may have met a // until that read lands; a failed one stays shown, and may have met a
// plan changed meanwhile. // plan changed meanwhile. The Quest log is read again too, as after a
// swap: the draft may have planned some of it.
const [readJobId, setReadJobId] = useState<string | null>(null) const [readJobId, setReadJobId] = useState<string | null>(null)
const ended = job.status === 'succeeded' || job.status === 'failed' const ended = job.status === 'succeeded' || job.status === 'failed'
useEffect(() => { useEffect(() => {
if (!ended) return if (!ended) return
let active = true let active = true
void queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => { void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => {
if (active) setReadJobId(jobId) if (active) setReadJobId(jobId)
}) })