diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 27e3e46..e4f55e5 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -793,13 +793,13 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
- Where each event is emitted, with its idempotency key. Each is stored by
`notify.EmitTx` in the transaction of the change it is about, so the
change and its notification commit together or not at all:
- - `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the
+ - `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job stores the
AI's draft of a week. Only the first draft of a week notifies: a
redraft (Rebuild week) has the same key, and a manual swap never
drafts. Its title is "Next week's plan is ready" (or "This week's",
or "The plan for the week of Oct 26") and its body the drafted
dinners Monday to Sunday.
- - `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
+ - `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply
run is first created, by `POST /shopping/{weekStart}/generate` or
after Push to Calendar; remaking it does not notify.
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
@@ -1098,6 +1098,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
```json
{
"dateLabel": "Friday, Oct 2",
+ "timezone": "America/Chicago",
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
"sideQuestCount": 10,
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
@@ -1107,8 +1108,11 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
`tonight` is today's day of the current week's plan (`plan.Get`), null
when the week has no plan; it changes at the household's local
- midnight. `photo` and `minutes_total` are null when unknown.
- `sideQuestCount` is the pending cards of the deck (`suggest.Deck`).
+ midnight, so the PWA reads Home again at the next local midnight of
+ `timezone`, the household's IANA zone. `photo` and `minutes_total` are
+ null when unknown. `sideQuestCount` is the pending cards of the deck,
+ read without writing (`suggest.PendingCount`): a Home read never fails
+ orphaned batches the way a `suggest.Deck` read does.
`shoppingList` is the upcoming week's Supply run when it has one, else
the current week's (`shopping.Get`); with neither, it is the current
week with `ready` false. `recentUpdates.unread` is true when a
@@ -1123,7 +1127,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
one update):
```json
- {"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan", "created_at": "2026-10-09T14:00:00.000Z"}]}
+ {"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]}
```
Every field comes from the stored payload, which never holds a webhook
diff --git a/internal/chat/chat_test.go b/internal/chat/chat_test.go
index e06d9e7..aa9b804 100644
--- a/internal/chat/chat_test.go
+++ b/internal/chat/chat_test.go
@@ -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 {
diff --git a/internal/chat/messages.go b/internal/chat/messages.go
index eadf111..1ab7a40 100644
--- a/internal/chat/messages.go
+++ b/internal/chat/messages.go
@@ -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,
}
}
diff --git a/internal/chat/messages_test.go b/internal/chat/messages_test.go
index 5a9f6a3..20468d6 100644
--- a/internal/chat/messages_test.go
+++ b/internal/chat/messages_test.go
@@ -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",
diff --git a/internal/home/home.go b/internal/home/home.go
index 735ab40..0532186 100644
--- a/internal/home/home.go
+++ b/internal/home/home.go
@@ -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
diff --git a/internal/home/home_test.go b/internal/home/home_test.go
index fb53d7e..d606d15 100644
--- a/internal/home/home_test.go
+++ b/internal/home/home_test.go
@@ -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) {
diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go
index f4166d0..2ea0b38 100644
--- a/internal/plan/plan_test.go
+++ b/internal/plan/plan_test.go
@@ -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)
}
}
diff --git a/internal/server/home_test.go b/internal/server/home_test.go
index 9f98be7..513ddb2 100644
--- a/internal/server/home_test.go
+++ b/internal/server/home_test.go
@@ -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},
diff --git a/internal/shopping/notify_test.go b/internal/shopping/notify_test.go
index eb23d45..873f3a1 100644
--- a/internal/shopping/notify_test.go
+++ b/internal/shopping/notify_test.go
@@ -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)
}
}
diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go
index 8042a5f..23fa9d9 100644
--- a/internal/suggest/suggest.go
+++ b/internal/suggest/suggest.go
@@ -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.
diff --git a/web/src/home/HomePage.test.tsx b/web/src/home/HomePage.test.tsx
index 490c166..09c4e34 100644
--- a/web/src/home/HomePage.test.tsx
+++ b/web/src/home/HomePage.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen, within } from '@testing-library/react'
+import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
@@ -20,6 +20,7 @@ const NOTHING_YET = {
}
afterEach(() => {
+ vi.useRealTimers()
vi.unstubAllGlobals()
})
@@ -72,6 +73,31 @@ describe('HomePage', () => {
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
})
+ it("reads Home again at the household's next local midnight, so a Home left open moves to the new day", async () => {
+ // setTimeout moves with real time, and the test jumps to midnight.
+ vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
+ // 23:58 on Friday Oct 2 in New York, the household's zone.
+ vi.setSystemTime(new Date('2026-10-03T03:58:00Z'))
+ const days = [HOME, { ...HOME, dateLabel: 'Saturday, Oct 3', tonight: null }]
+ let reads = 0
+ stubFetchByPath({
+ [HOME_PATH]: () => jsonResponse(200, days[Math.min(reads++, 1)]),
+ [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
+ })
+ renderRouter()
+ expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
+ expect(reads).toBe(1)
+
+ // A minute before midnight Home is not read again.
+ await act(() => vi.advanceTimersByTimeAsync(60_000))
+ expect(reads).toBe(1)
+ // At midnight it is, and shows the new day.
+ await act(() => vi.advanceTimersByTimeAsync(60_000))
+ expect(await screen.findByText('Saturday, Oct 3')).toBeInTheDocument()
+ expect(reads).toBe(2)
+ expect(screen.getByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
+ })
+
it('shows the grape unread dot on the bell when there are unread updates', async () => {
renderHome(HOME)
diff --git a/web/src/home/RotationCheck.test.tsx b/web/src/home/RotationCheck.test.tsx
index 152498e..875e9cd 100644
--- a/web/src/home/RotationCheck.test.tsx
+++ b/web/src/home/RotationCheck.test.tsx
@@ -232,6 +232,45 @@ describe('RotationCheck', () => {
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
})
+ it('keeps a card already shown, with an ErrorBanner beside it, when reading the checks again fails', async () => {
+ // navigator.serviceWorker is where the worker's postMessage to this
+ // window arrives; a notification tap there reads the checks again.
+ const container = new EventTarget()
+ Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
+ const answers = [jsonResponse(200, { reviews: [SHAKSHUKA] }), errorResponse(500, 'internal')]
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn
{useLocation().search}
+} + // pushesKept counts the pushes of the week of Oct 5 the mutation cache of // queryClient keeps. function pushesKept(queryClient: QueryClient) { @@ -1114,6 +1128,30 @@ describe('PlanPage', () => { expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument() }) + it('opens on the week of ?week=, as a plan_ready update for an older week links it', async () => { + stubServer({ + [`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'), + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + }) + await renderPage(createQueryClient(),