Fix Home dashboard integration findings (Feature 21)
CI / ci (push) Successful in 8m57s
CI / ci (pull_request) Successful in 8m27s

- F21-01: plan_ready and list_ready links carry their week
  (/plan?week=, /plan/shopping?week=), and the Plan opens on ?week=
  and keeps it on the week shown, so an older update opens its week.
- F21-02: a failed refresh of the Rotation check shows its ErrorBanner
  beside the cached pending card instead of replacing it.
- F21-03: Home carries the household's timezone and is read again at
  its next local midnight.
- F21-04: Home counts pending Side quests through a read-only query
  (suggest.PendingCount), so reading Home never writes batches.

Task: ac3dddf0-a05f-4e43-a5ad-3105fe6bdd95

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 18:03:52 -04:00
co-authored by Claude Opus 5.5
parent 62f4f9eece
commit 0a69efea5c
22 changed files with 312 additions and 60 deletions
+2 -2
View File
@@ -342,8 +342,8 @@ func TestSendThreadsByWeek(t *testing.T) {
t.Errorf("plan text = %q", reqs[0].Body["text"])
}
// The cards' "Open" buttons link under the public URL.
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} {
// The cards' "Open" buttons link under the public URL to the event's week.
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan?week=2026-10-12", 1: "https://mealquest.example/plan/shopping?week=2026-10-12"} {
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
var cards []cardV2
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
+3 -2
View File
@@ -2,6 +2,7 @@ package chat
import (
"fmt"
"net/url"
"strings"
"time"
@@ -46,7 +47,7 @@ func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
Key: string(notify.PlanReady) + ":" + week.String(),
Title: planTitle(week, thisWeek),
Body: nameList(dinners),
URL: "/plan",
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
Week: week,
}
}
@@ -59,7 +60,7 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
Key: string(notify.ListReady) + ":" + week.String(),
Title: "This week's Supply run is ready",
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
URL: "/plan/shopping",
URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(),
Week: week,
}
}
+6 -6
View File
@@ -38,7 +38,7 @@ func TestMessages(t *testing.T) {
name: "plan ready",
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
key: "plan_ready:2026-10-12",
url: "/plan",
url: "/plan?week=2026-10-12",
week: "2026-10-12",
title: "This week's plan is ready",
body: "Tacos, Bolognese, Tikka masala + 4 more",
@@ -48,7 +48,7 @@ func TestMessages(t *testing.T) {
name: "plan ready with three dinners",
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
key: "plan_ready:2026-10-12",
url: "/plan",
url: "/plan?week=2026-10-12",
week: "2026-10-12",
title: "This week's plan is ready",
body: "Tacos, Bolognese, Tikka masala",
@@ -58,7 +58,7 @@ func TestMessages(t *testing.T) {
name: "plan ready for next week",
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
key: "plan_ready:2026-10-12",
url: "/plan",
url: "/plan?week=2026-10-12",
week: "2026-10-12",
title: "Next week's plan is ready",
body: "Tacos, Bolognese, Tikka masala + 1 more",
@@ -68,7 +68,7 @@ func TestMessages(t *testing.T) {
name: "plan ready for a later week",
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
key: "plan_ready:2026-10-26",
url: "/plan",
url: "/plan?week=2026-10-26",
week: "2026-10-26",
title: "The plan for the week of Oct 26 is ready",
body: "Tacos",
@@ -78,7 +78,7 @@ func TestMessages(t *testing.T) {
name: "list ready",
ev: ListReady(week, 19, 7),
key: "list_ready:2026-10-12",
url: "/plan/shopping",
url: "/plan/shopping?week=2026-10-12",
week: "2026-10-12",
title: "This week's Supply run is ready",
body: "19 items to buy for 7 dinners",
@@ -88,7 +88,7 @@ func TestMessages(t *testing.T) {
name: "list ready singular",
ev: ListReady(week, 1, 1),
key: "list_ready:2026-10-12",
url: "/plan/shopping",
url: "/plan/shopping?week=2026-10-12",
week: "2026-10-12",
title: "This week's Supply run is ready",
body: "1 item to buy for 1 dinner",
+8 -3
View File
@@ -50,6 +50,9 @@ func NewService(settings *household.SettingsService, plans *plan.Service, sugges
type Home struct {
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
DateLabel string `json:"dateLabel"`
// Timezone is the household's IANA zone, whose next local midnight
// is when Home is read again for the new day.
Timezone string `json:"timezone"`
// Tonight is the dinner planned for today on the current week's plan;
// nil when the week has no plan.
Tonight *Dinner `json:"tonight"`
@@ -92,7 +95,8 @@ type RecentUpdates struct {
Unread bool `json:"unread"`
}
// Get reads the Home screen at the service's "now".
// Get reads the Home screen at the service's "now". It only reads: it
// writes nothing, not even the batch bookkeeping a Deck read does.
func (s *Service) Get(ctx context.Context) (Home, error) {
settings, err := s.settings.Get(ctx)
if err != nil {
@@ -110,7 +114,7 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
if err != nil {
return Home{}, err
}
deck, err := s.suggest.Deck(ctx)
sideQuests, err := s.suggest.PendingCount(ctx)
if err != nil {
return Home{}, fmt.Errorf("home: read deck: %w", err)
}
@@ -124,8 +128,9 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
}
return Home{
DateLabel: now.Format("Monday, Jan 2"),
Timezone: settings.Timezone,
Tonight: tonight,
SideQuestCount: len(deck.Suggestions),
SideQuestCount: sideQuests,
ShoppingList: list,
RecentUpdates: RecentUpdates{Unread: unread},
}, nil
+27
View File
@@ -245,6 +245,7 @@ func TestHomeEmpty(t *testing.T) {
h := e.at(t, "2026-10-13 23:59")
want := home.Home{
DateLabel: "Tuesday, Oct 13",
Timezone: zone,
ShoppingList: home.ShoppingStatus{
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
},
@@ -289,6 +290,32 @@ func TestSideQuestCount(t *testing.T) {
}
}
// Reading Home writes nothing: a batch still pending whose job is gone,
// which a Deck read would fail, is left as it was, and the read succeeds.
func TestGetDoesNotWriteBatches(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
created := "2026-10-13T12:00:00.000Z"
batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created})
if err != nil {
t.Fatal(err)
}
if batch.Status != "pending" {
t.Fatalf("new batch status = %q, want pending", batch.Status)
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 {
t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount)
}
var status, updatedAt string
if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil {
t.Fatal(err)
}
if status != "pending" || updatedAt != created {
t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created)
}
}
// The Supply run shown is the upcoming week's when it has one, else the
// current week's; the weeks are the household's local weeks.
func TestShoppingStatus(t *testing.T) {
+2 -2
View File
@@ -375,9 +375,9 @@ func TestDraftEmitsPlanReady(t *testing.T) {
}
for _, r := range rows {
p := r.Payload
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan?week=2026-10-12" || p.Week != "2026-10-12" ||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title)
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan?week=2026-10-12", r.Channel, r, want.Title)
}
}
+1
View File
@@ -35,6 +35,7 @@ func TestGetHome(t *testing.T) {
}
body, err := json.Marshal(map[string]any{
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
"timezone": loc.String(),
"tonight": nil,
"sideQuestCount": 0,
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
+2 -2
View File
@@ -37,9 +37,9 @@ func TestGenerateEmitsListReadyOnce(t *testing.T) {
}
for _, r := range rows {
p := r.Payload
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping?week=2026-10-12" || p.Week != "2026-10-12" ||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping?week=2026-10-12", r.Channel, r)
}
}
+11
View File
@@ -346,6 +346,17 @@ func (s *Service) Deck(ctx context.Context) (Deck, error) {
return deck, nil
}
// PendingCount is how many Side quests are pending on the deck. Unlike
// Deck it only reads: it does not fail orphaned batches, so a reader such
// as Home never writes.
func (s *Service) PendingCount(ctx context.Context) (int, error) {
rows, err := db.New(s.st.Reader).ListDeck(ctx)
if err != nil {
return 0, fmt.Errorf("suggest: list deck: %w", err)
}
return len(rows), nil
}
// Decision is a suggestion once decided, with the id of the replenish
// batch's job when the decision called for one: the batch it started, or
// the one already pending or running that it joined.