Surface replenish failures, follow the active batch, scope deck keys, keep three deck depths
Fixes the Feature 13 review findings on the Discover deck (Maestro task 4fc551f0-3c11-4c15-b638-f2c08a28eb29): - F13-01: a batch failure (start, poll or job) is shown with its Retry whatever the deck has, so a replenish that fails while cards remain is no longer silent until the deck runs out. - F13-02: GET /api/v1/suggestions/deck reports the batch pending or running (active_batch, read in the deck's snapshot), and a decision that calls for a replenish returns the job of the batch it started or joined. The page follows a newly reported active batch, so a reopened page or a second session shows skeleton cards while the batch runs instead of "All caught up". - F13-03: ← / → leave the deck alone while it is inert behind a modal (the Quest log sheet or its confirmation). - F13-04: a one- or two-card deck keeps all three depths: skeleton cards stand in for the missing ones while a batch finds more Side quests, blank stack-edge cards while none is coming. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+43
-15
@@ -261,25 +261,54 @@ type Suggestion struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Deck returns the pending suggestions whose dinner is still suggested,
|
||||
// oldest first.
|
||||
func (s *Service) Deck(ctx context.Context) ([]Suggestion, error) {
|
||||
rows, err := db.New(s.st.Reader).ListDeck(ctx)
|
||||
// Deck is the household's deck: the pending suggestions whose dinner is
|
||||
// still suggested, oldest first, and the batch pending or running, if any,
|
||||
// so a client can follow the job producing more.
|
||||
type Deck struct {
|
||||
Suggestions []Suggestion `json:"suggestions"`
|
||||
ActiveBatch *Batch `json:"active_batch"`
|
||||
}
|
||||
|
||||
// Deck reads the deck and its active batch in one read transaction, so
|
||||
// both come from the same snapshot.
|
||||
func (s *Service) Deck(ctx context.Context) (deck Deck, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: list deck: %w", err)
|
||||
return Deck{}, fmt.Errorf("suggest: begin deck read: %w", err)
|
||||
}
|
||||
out := make([]Suggestion, len(rows))
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
rows, err := q.ListDeck(ctx)
|
||||
if err != nil {
|
||||
return Deck{}, fmt.Errorf("suggest: list deck: %w", err)
|
||||
}
|
||||
deck.Suggestions = make([]Suggestion, len(rows))
|
||||
for i, row := range rows {
|
||||
out[i], err = fromRow(row.Suggestion, row.Dinner)
|
||||
deck.Suggestions[i], err = fromRow(row.Suggestion, row.Dinner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return Deck{}, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
cur, err := q.GetActiveSuggestionBatch(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return deck, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Deck{}, fmt.Errorf("suggest: read active batch: %w", err)
|
||||
}
|
||||
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
|
||||
if err != nil {
|
||||
return Deck{}, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
|
||||
}
|
||||
deck.ActiveBatch = &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}
|
||||
return deck, nil
|
||||
}
|
||||
|
||||
// Decision is a suggestion once decided, with the id of the replenish
|
||||
// batch's job when the decision started one.
|
||||
// batch's job when the decision called for one: the batch it started, or
|
||||
// the one already pending or running that it joined.
|
||||
type Decision struct {
|
||||
Suggestion Suggestion `json:"suggestion"`
|
||||
ReplenishJobID *string `json:"replenish_job_id"`
|
||||
@@ -303,8 +332,8 @@ func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision,
|
||||
// dinnerStatus. Repeating a decision changes nothing and returns the
|
||||
// suggestion as it is; the opposite decision is a *DecidedError. A
|
||||
// decision that leaves the deck under ReplenishBelow starts a replenish
|
||||
// batch unless one is already pending or running, in the decision's own
|
||||
// transaction: the decision is never recorded without the replenish it
|
||||
// batch, or joins the one already pending or running, in the decision's own
|
||||
// transaction, and returns its job id either way: the decision is never recorded without the replenish it
|
||||
// calls for, and a failure to start the batch records neither.
|
||||
func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) {
|
||||
if updatedBy == "" {
|
||||
@@ -369,8 +398,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
created := replenish != nil && replenish.Created
|
||||
if created {
|
||||
if replenish != nil && replenish.Created {
|
||||
s.batchJob.Wake()
|
||||
}
|
||||
sug, err := fromRow(row.Suggestion, row.Dinner)
|
||||
@@ -378,7 +406,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
|
||||
return Decision{}, err
|
||||
}
|
||||
d := Decision{Suggestion: sug}
|
||||
if created {
|
||||
if replenish != nil {
|
||||
d.ReplenishJobID = &replenish.JobID
|
||||
}
|
||||
return d, nil
|
||||
|
||||
@@ -329,7 +329,7 @@ func (e *env) deckNames(t *testing.T) []string {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := []string{}
|
||||
for _, s := range deck {
|
||||
for _, s := range deck.Suggestions {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
return names
|
||||
@@ -362,15 +362,15 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(deck) != 2 {
|
||||
t.Fatalf("deck has %d suggestions, want 2", len(deck))
|
||||
if len(deck.Suggestions) != 2 {
|
||||
t.Fatalf("deck has %d suggestions, want 2", len(deck.Suggestions))
|
||||
}
|
||||
want := []struct{ name, reason, regular string }{
|
||||
{"Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"},
|
||||
{"Chili Verde", "Your Beef Chili gone green.", "Beef Chili"},
|
||||
}
|
||||
for i, w := range want {
|
||||
s := deck[i]
|
||||
s := deck.Suggestions[i]
|
||||
if s.ID != res.SuggestionIDs[i] || s.Name != w.name || s.Reason != w.reason || s.Status != suggest.StatusPending {
|
||||
t.Errorf("deck[%d] = %+v, want %s pending with reason %q", i, s, w.name, w.reason)
|
||||
}
|
||||
@@ -452,6 +452,33 @@ func TestDeckIsOldestFirst(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeckReportsTheActiveBatch(t *testing.T) {
|
||||
e := newEnv(t) // runner not started: the batch stays pending
|
||||
e.seedDeck(t, "Pho")
|
||||
deck, err := e.svc.Deck(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deck.ActiveBatch != nil {
|
||||
t.Fatalf("active batch = %+v with none pending or running, want nil", deck.ActiveBatch)
|
||||
}
|
||||
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deck, err = e.svc.Deck(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(deck.Suggestions) != 1 {
|
||||
t.Errorf("deck has %d suggestions, want 1", len(deck.Suggestions))
|
||||
}
|
||||
want := suggest.Batch{ID: b.ID, JobID: b.JobID, Trigger: suggest.TriggerManual, Status: suggest.BatchPending}
|
||||
if deck.ActiveBatch == nil || *deck.ActiveBatch != want {
|
||||
t.Errorf("active batch = %+v, want %+v", deck.ActiveBatch, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeckLeavesOutDinnersNoLongerSuggested(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.seedDeck(t, "Pho", "Ramen")
|
||||
@@ -459,7 +486,7 @@ func TestDeckLeavesOutDinnersNoLongerSuggested(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: sug[0].DinnerID, UpdatedAt: store.Now().String()}); err != nil {
|
||||
if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: sug.Suggestions[0].DinnerID, UpdatedAt: store.Now().String()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := e.deckNames(t); !slices.Equal(got, []string{"Ramen"}) {
|
||||
@@ -608,8 +635,10 @@ func TestReplenishIsSingleFlight(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.ReplenishJobID != nil {
|
||||
t.Errorf("second decision enqueued replenish job %s while one is pending", *second.ReplenishJobID)
|
||||
// The second decision joins the pending batch and reports its job, so a
|
||||
// client that did not start it can follow it.
|
||||
if second.ReplenishJobID == nil || *second.ReplenishJobID != *first.ReplenishJobID {
|
||||
t.Errorf("second decision replenish job = %v, want the pending batch's job %s", second.ReplenishJobID, *first.ReplenishJobID)
|
||||
}
|
||||
manual, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user