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
+9 -5
View File
@@ -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 - 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 `notify.EmitTx` in the transaction of the change it is about, so the
change and its notification commit together or not at all: 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 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 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", 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 or "The plan for the week of Oct 26") and its body the drafted
dinners Monday to Sunday. 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 run is first created, by `POST /shopping/{weekStart}/generate` or
after Push to Calendar; remaking it does not notify. after Push to Calendar; remaking it does not notify.
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or - `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
@@ -1098,6 +1098,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
```json ```json
{ {
"dateLabel": "Friday, Oct 2", "dateLabel": "Friday, Oct 2",
"timezone": "America/Chicago",
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40}, "tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
"sideQuestCount": 10, "sideQuestCount": 10,
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null}, "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 `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 when the week has no plan; it changes at the household's local
midnight. `photo` and `minutes_total` are null when unknown. midnight, so the PWA reads Home again at the next local midnight of
`sideQuestCount` is the pending cards of the deck (`suggest.Deck`). `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 `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 the current week's (`shopping.Get`); with neither, it is the current
week with `ready` false. `recentUpdates.unread` is true when a 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): one update):
```json ```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 Every field comes from the stored payload, which never holds a webhook
+2 -2
View File
@@ -342,8 +342,8 @@ func TestSendThreadsByWeek(t *testing.T) {
t.Errorf("plan text = %q", reqs[0].Body["text"]) t.Errorf("plan text = %q", reqs[0].Body["text"])
} }
// The cards' "Open" buttons link under the public URL. // 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", 1: "https://mealquest.example/plan/shopping"} { 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"]) data, _ := json.Marshal(reqs[i].Body["cardsV2"])
var cards []cardV2 var cards []cardV2
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 { if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
+3 -2
View File
@@ -2,6 +2,7 @@ package chat
import ( import (
"fmt" "fmt"
"net/url"
"strings" "strings"
"time" "time"
@@ -46,7 +47,7 @@ func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
Key: string(notify.PlanReady) + ":" + week.String(), Key: string(notify.PlanReady) + ":" + week.String(),
Title: planTitle(week, thisWeek), Title: planTitle(week, thisWeek),
Body: nameList(dinners), Body: nameList(dinners),
URL: "/plan", URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
Week: week, Week: week,
} }
} }
@@ -59,7 +60,7 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
Key: string(notify.ListReady) + ":" + week.String(), Key: string(notify.ListReady) + ":" + week.String(),
Title: "This week's Supply run is ready", Title: "This week's Supply run is ready",
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")), 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, Week: week,
} }
} }
+6 -6
View File
@@ -38,7 +38,7 @@ func TestMessages(t *testing.T) {
name: "plan ready", name: "plan ready",
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}), ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
key: "plan_ready:2026-10-12", key: "plan_ready:2026-10-12",
url: "/plan", url: "/plan?week=2026-10-12",
week: "2026-10-12", week: "2026-10-12",
title: "This week's plan is ready", title: "This week's plan is ready",
body: "Tacos, Bolognese, Tikka masala + 4 more", body: "Tacos, Bolognese, Tikka masala + 4 more",
@@ -48,7 +48,7 @@ func TestMessages(t *testing.T) {
name: "plan ready with three dinners", name: "plan ready with three dinners",
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}), ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
key: "plan_ready:2026-10-12", key: "plan_ready:2026-10-12",
url: "/plan", url: "/plan?week=2026-10-12",
week: "2026-10-12", week: "2026-10-12",
title: "This week's plan is ready", title: "This week's plan is ready",
body: "Tacos, Bolognese, Tikka masala", body: "Tacos, Bolognese, Tikka masala",
@@ -58,7 +58,7 @@ func TestMessages(t *testing.T) {
name: "plan ready for next week", name: "plan ready for next week",
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}), ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
key: "plan_ready:2026-10-12", key: "plan_ready:2026-10-12",
url: "/plan", url: "/plan?week=2026-10-12",
week: "2026-10-12", week: "2026-10-12",
title: "Next week's plan is ready", title: "Next week's plan is ready",
body: "Tacos, Bolognese, Tikka masala + 1 more", body: "Tacos, Bolognese, Tikka masala + 1 more",
@@ -68,7 +68,7 @@ func TestMessages(t *testing.T) {
name: "plan ready for a later week", name: "plan ready for a later week",
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}), ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
key: "plan_ready:2026-10-26", key: "plan_ready:2026-10-26",
url: "/plan", url: "/plan?week=2026-10-26",
week: "2026-10-26", week: "2026-10-26",
title: "The plan for the week of Oct 26 is ready", title: "The plan for the week of Oct 26 is ready",
body: "Tacos", body: "Tacos",
@@ -78,7 +78,7 @@ func TestMessages(t *testing.T) {
name: "list ready", name: "list ready",
ev: ListReady(week, 19, 7), ev: ListReady(week, 19, 7),
key: "list_ready:2026-10-12", key: "list_ready:2026-10-12",
url: "/plan/shopping", url: "/plan/shopping?week=2026-10-12",
week: "2026-10-12", week: "2026-10-12",
title: "This week's Supply run is ready", title: "This week's Supply run is ready",
body: "19 items to buy for 7 dinners", body: "19 items to buy for 7 dinners",
@@ -88,7 +88,7 @@ func TestMessages(t *testing.T) {
name: "list ready singular", name: "list ready singular",
ev: ListReady(week, 1, 1), ev: ListReady(week, 1, 1),
key: "list_ready:2026-10-12", key: "list_ready:2026-10-12",
url: "/plan/shopping", url: "/plan/shopping?week=2026-10-12",
week: "2026-10-12", week: "2026-10-12",
title: "This week's Supply run is ready", title: "This week's Supply run is ready",
body: "1 item to buy for 1 dinner", 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 { type Home struct {
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2". // DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
DateLabel string `json:"dateLabel"` 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; // Tonight is the dinner planned for today on the current week's plan;
// nil when the week has no plan. // nil when the week has no plan.
Tonight *Dinner `json:"tonight"` Tonight *Dinner `json:"tonight"`
@@ -92,7 +95,8 @@ type RecentUpdates struct {
Unread bool `json:"unread"` 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) { func (s *Service) Get(ctx context.Context) (Home, error) {
settings, err := s.settings.Get(ctx) settings, err := s.settings.Get(ctx)
if err != nil { if err != nil {
@@ -110,7 +114,7 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
if err != nil { if err != nil {
return Home{}, err return Home{}, err
} }
deck, err := s.suggest.Deck(ctx) sideQuests, err := s.suggest.PendingCount(ctx)
if err != nil { if err != nil {
return Home{}, fmt.Errorf("home: read deck: %w", err) return Home{}, fmt.Errorf("home: read deck: %w", err)
} }
@@ -124,8 +128,9 @@ func (s *Service) Get(ctx context.Context) (Home, error) {
} }
return Home{ return Home{
DateLabel: now.Format("Monday, Jan 2"), DateLabel: now.Format("Monday, Jan 2"),
Timezone: settings.Timezone,
Tonight: tonight, Tonight: tonight,
SideQuestCount: len(deck.Suggestions), SideQuestCount: sideQuests,
ShoppingList: list, ShoppingList: list,
RecentUpdates: RecentUpdates{Unread: unread}, RecentUpdates: RecentUpdates{Unread: unread},
}, nil }, nil
+27
View File
@@ -245,6 +245,7 @@ func TestHomeEmpty(t *testing.T) {
h := e.at(t, "2026-10-13 23:59") h := e.at(t, "2026-10-13 23:59")
want := home.Home{ want := home.Home{
DateLabel: "Tuesday, Oct 13", DateLabel: "Tuesday, Oct 13",
Timezone: zone,
ShoppingList: home.ShoppingStatus{ ShoppingList: home.ShoppingStatus{
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", 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 // 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. // current week's; the weeks are the household's local weeks.
func TestShoppingStatus(t *testing.T) { func TestShoppingStatus(t *testing.T) {
+2 -2
View File
@@ -375,9 +375,9 @@ func TestDraftEmitsPlanReady(t *testing.T) {
} }
for _, r := range rows { for _, r := range rows {
p := r.Payload 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" { 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{ body, err := json.Marshal(map[string]any{
"dateLabel": now.In(loc).Format("Monday, Jan 2"), "dateLabel": now.In(loc).Format("Monday, Jan 2"),
"timezone": loc.String(),
"tonight": nil, "tonight": nil,
"sideQuestCount": 0, "sideQuestCount": 0,
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil}, "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 { for _, r := range rows {
p := r.Payload 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" { 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 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 // 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 // batch's job when the decision called for one: the batch it started, or
// the one already pending or running that it joined. // the one already pending or running that it joined.
+27 -1
View File
@@ -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 { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProviders, HOME } from '../test/ApiProviders.tsx' import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
@@ -20,6 +20,7 @@ const NOTHING_YET = {
} }
afterEach(() => { afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals() vi.unstubAllGlobals()
}) })
@@ -72,6 +73,31 @@ describe('HomePage', () => {
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument() 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 () => { it('shows the grape unread dot on the bell when there are unread updates', async () => {
renderHome(HOME) renderHome(HOME)
+39
View File
@@ -232,6 +232,45 @@ describe('RotationCheck', () => {
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument() 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<typeof fetch>((input) =>
Promise.resolve(
String(input) === ME_PATH
? jsonResponse(200, ME)
: String(input) === HOME_PATH
? jsonResponse(200, HOME)
: answers.shift()!,
),
),
)
await renderHome()
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
const origin = 'https://mealquest.test'
const win = {
url: `${origin}/`,
focused: true,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
} satisfies ClickWindow
await act(() =>
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
)
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
const shown = screen.getByRole('region', { name: 'Keep it in rotation?' })
expect(within(shown).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(within(shown).getByRole('button', { name: 'Keep it' })).toBeEnabled()
})
it('opens Home with the card from a rotation_review push notification', async () => { it('opens Home with the card from a rotation_review push notification', async () => {
stubApi([SHAKSHUKA]) stubApi([SHAKSHUKA])
const origin = 'https://mealquest.test' const origin = 'https://mealquest.test'
+8 -8
View File
@@ -44,17 +44,12 @@ function RotationCard({ review, disabled, onDecide }: RotationCardProps) {
// household answers it — nothing is decided for them. An answer is applied // household answers it — nothing is decided for them. An answer is applied
// at once: Home confirms it and shows the next pending check, if any, while // at once: Home confirms it and shows the next pending check, if any, while
// the answer is saved; the answers are locked until it settles. A failed // the answer is saved; the answers are locked until it settles. A failed
// answer puts its check back with an ErrorBanner. // answer puts its check back with an ErrorBanner. A failed read shows an
// ErrorBanner, beside the checks last read: a card already shown stays
// until it is answered.
export function RotationCheck() { export function RotationCheck() {
const reviews = useRotationReviews() const reviews = useRotationReviews()
const decide = useDecideRotationReview() const decide = useDecideRotationReview()
if (reviews.isError) {
return (
<ErrorBanner onRetry={() => void reviews.refetch()}>
Couldn't load the Rotation check. {reviews.error.message}
</ErrorBanner>
)
}
const deciding = decide.isPending ? decide.variables.review.dinner.id : null const deciding = decide.isPending ? decide.variables.review.dinner.id : null
const next = reviews.data?.find((review) => review.dinner.id !== deciding) const next = reviews.data?.find((review) => review.dinner.id !== deciding)
const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined
@@ -65,6 +60,11 @@ export function RotationCheck() {
{CONFIRMATION[answered.decision]} {CONFIRMATION[answered.decision]}
</p> </p>
)} )}
{reviews.isError && (
<ErrorBanner onRetry={() => void reviews.refetch()}>
Couldn't load the Rotation check. {reviews.error.message}
</ErrorBanner>
)}
{decide.isError && <ErrorBanner>Couldn't save that answer. {decide.error.message}</ErrorBanner>} {decide.isError && <ErrorBanner>Couldn't save that answer. {decide.error.message}</ErrorBanner>}
{next !== undefined && ( {next !== undefined && (
<RotationCard <RotationCard
+2 -2
View File
@@ -28,7 +28,7 @@ const UPDATES = {
type: 'plan_ready', type: 'plan_ready',
title: 'Your week is drafted', title: 'Your week is drafted',
body: 'Take a look at the Draft board.', body: 'Take a look at the Draft board.',
url: '/plan', url: '/plan?week=2026-10-12',
created_at: minutesAgo(5), created_at: minutesAgo(5),
}, },
{ type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) }, { type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) },
@@ -311,7 +311,7 @@ describe('Recent updates sheet', () => {
}) })
it.each([ it.each([
[0, '/plan'], [0, '/plan?week=2026-10-12'],
[1, '/discover'], [1, '/discover'],
[2, '/plan/shopping?week=2026-10-05'], [2, '/plan/shopping?week=2026-10-05'],
])('row %i goes to %s and closes the sheet', async (i, to) => { ])('row %i goes to %s and closes the sheet', async (i, to) => {
+14 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { parseUpdates } from './api.ts' import { HOME } from '../test/ApiProviders.tsx'
import { parseHome, parseUpdates } from './api.ts'
const origin = 'https://mealquest.example' const origin = 'https://mealquest.example'
const where = 'GET /api/v1/updates' const where = 'GET /api/v1/updates'
@@ -37,3 +38,15 @@ describe('parseUpdates', () => {
) )
}) })
}) })
describe('parseHome', () => {
it("keeps the household's zone, and refuses one that is not an IANA zone", () => {
expect(parseHome('GET /api/v1/home', HOME).timezone).toBe('America/New_York')
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: 'Mars/Olympus' })).toThrow(
'GET /api/v1/home: timezone "Mars/Olympus" is not an IANA zone',
)
expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: undefined })).toThrow(
'GET /api/v1/home: timezone undefined is not an IANA zone',
)
})
})
+35 -4
View File
@@ -1,8 +1,9 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useApi } from '../api/index.ts' import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts' import { isRecord } from '../api/errors.ts'
import { oneOf, photoId } from '../dinners/api.ts' import { oneOf, photoId } from '../dinners/api.ts'
import { weekLabel } from '../plan/week.ts' import { untilLocalMidnight, weekLabel } from '../plan/week.ts'
import { appPath } from '../push/notification.ts' import { appPath } from '../push/notification.ts'
export const HOME_PATH = '/api/v1/home' export const HOME_PATH = '/api/v1/home'
@@ -27,11 +28,13 @@ export type Tonight = { id: string; name: string; photo_id: string | undefined;
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean } export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday, // Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
// Oct 2"; tonight is null when today has no plan; sideQuestCount is how // Oct 2"; timezone is the household's IANA zone, whose midnight starts the
// next day; tonight is null when today has no plan; sideQuestCount is how
// many Side quests wait in the deck; unread says the Recent updates hold // many Side quests wait in the deck; unread says the Recent updates hold
// something not yet seen. // something not yet seen.
export type Home = { export type Home = {
dateLabel: string dateLabel: string
timezone: string
tonight: Tonight | null tonight: Tonight | null
sideQuestCount: number sideQuestCount: number
shoppingList: ShoppingListStatus shoppingList: ShoppingListStatus
@@ -77,6 +80,9 @@ function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
// parseHome checks a GET /api/v1/home body. // parseHome checks a GET /api/v1/home body.
export function parseHome(where: string, body: unknown): Home { export function parseHome(where: string, body: unknown): Home {
if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`) if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`)
if (typeof body.timezone !== 'string' || !isZone(body.timezone)) {
throw new Error(`${where}: timezone ${JSON.stringify(body.timezone)} is not an IANA zone`)
}
if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) { if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) {
throw new Error(`${where}: sideQuestCount is not a count`) throw new Error(`${where}: sideQuestCount is not a count`)
} }
@@ -84,6 +90,7 @@ export function parseHome(where: string, body: unknown): Home {
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`) if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
return { return {
dateLabel: body.dateLabel, dateLabel: body.dateLabel,
timezone: body.timezone,
tonight: parseTonight(where, body.tonight), tonight: parseTonight(where, body.tonight),
sideQuestCount: body.sideQuestCount as number, sideQuestCount: body.sideQuestCount as number,
shoppingList: parseShoppingList(where, body.shoppingList), shoppingList: parseShoppingList(where, body.shoppingList),
@@ -91,13 +98,37 @@ export function parseHome(where: string, body: unknown): Home {
} }
} }
// useHome is the Home screen's summary (GET /api/v1/home). // isZone says whether zone is an IANA zone this browser knows.
function isZone(zone: string): boolean {
try {
new Intl.DateTimeFormat('en-US', { timeZone: zone })
return true
} catch {
return false
}
}
// useHome is the Home screen's summary (GET /api/v1/home). Home is read
// again at the household's next local midnight, counted from each read, so
// a Home left open does not keep yesterday's date and dinner.
export function useHome() { export function useHome() {
const { apiFetch } = useApi() const { apiFetch } = useApi()
return useQuery({ const queryClient = useQueryClient()
const home = useQuery({
queryKey: HOME_KEY, queryKey: HOME_KEY,
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)), queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
}) })
const zone = home.data?.timezone
const readAt = home.dataUpdatedAt
useEffect(() => {
if (zone === undefined) return
const timer = setTimeout(
() => void queryClient.invalidateQueries({ queryKey: HOME_KEY }),
untilLocalMidnight(zone, new Date()),
)
return () => clearTimeout(timer)
}, [queryClient, zone, readAt])
return home
} }
export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const
+42 -4
View File
@@ -160,16 +160,25 @@ function planReads(fetchMock: Mock<typeof fetch>) {
} }
// renderPage loads /me (for the CSRF token every write needs) and renders // renderPage loads /me (for the CSRF token every write needs) and renders
// the Plan page, over queryClient, with shopping as the Supply run screen. // the Plan page at entry, over queryClient, with shopping as the Supply run
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) { // screen.
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />, entry = '/plan') {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe() await api.loadMe()
return render( return render(
<ApiProvider client={api}> <ApiProvider client={api}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan']}> <MemoryRouter initialEntries={[entry]}>
<Routes> <Routes>
<Route path="/plan" element={<PlanPage />} /> <Route
path="/plan"
element={
<>
<PlanPage />
<Searched />
</>
}
/>
<Route path="/plan/shopping" element={shopping} /> <Route path="/plan/shopping" element={shopping} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>
@@ -190,6 +199,11 @@ function Opened() {
) )
} }
// Searched shows the Plan's search, the week it keeps in ?week=.
function Searched() {
return <p data-testid="search">{useLocation().search}</p>
}
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of // pushesKept counts the pushes of the week of Oct 5 the mutation cache of
// queryClient keeps. // queryClient keeps.
function pushesKept(queryClient: QueryClient) { function pushesKept(queryClient: QueryClient) {
@@ -1114,6 +1128,30 @@ describe('PlanPage', () => {
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument() 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(), <Opened />, '/plan?week=2026-09-28')
expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument()
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
// The week buttons keep ?week= on the week shown.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-05')
})
it('says so when ?week= is not a Monday, and reads no plan', async () => {
const fetchMock = stubServer({ ...lists })
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-10-07')
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-10-07” is not the Monday of a week, as YYYY-MM-DD.')
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith('/api/v1/plans/'))).toBe(false)
})
it('moves between weeks with the previous and next week buttons', async () => { it('moves between weeks with the previous and next week buttons', async () => {
stubServer({ stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
+17 -7
View File
@@ -1,13 +1,13 @@
import { useQueryClient } from '@tanstack/react-query' 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, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router' import { Link, useSearchParams } from 'react-router'
import { ApiError, useJob } from '../api/index.ts' import { ApiError, useJob } from '../api/index.ts'
import { QUEST_LOG_KEY } from '../discover/api.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'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts' import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx' import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css' import buttonStyles from '../ui/Button.module.css'
@@ -40,6 +40,7 @@ import {
addDays, addDays,
dayOfMonth, dayOfMonth,
defaultWeek, defaultWeek,
isMonday,
localNow, localNow,
weekdayKicker, weekdayKicker,
weekdayName, weekdayName,
@@ -666,19 +667,28 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
// PlanPage is the Draft board on the week chosen with the previous / next // PlanPage is the Draft board on the week chosen with the previous / next
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the // week buttons: mock 1f on mobile; from tablet up the plan manager, with the
// Quest log as a strip under the grid on tablet (2c) and the Quest log and // Quest log as a strip under the grid on tablet (2c) and the Quest log and
// Regulars in a right panel on desktop (2d). It opens on next week once this // Regulars in a right panel on desktop (2d). It opens on the week chosen by
// ?week=, as a plan_ready update links it, else on next week once this
// week's plan_draft time has come in the household's zone, otherwise on this // week's plan_draft time has come in the household's zone, otherwise on this
// week. The board keeps its place in the tree across layouts, so crossing a // week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
// breakpoint keeps a draft being followed. From tablet up a dinner is // breakpoint keeps a draft being followed. From tablet up a dinner is
// dragged from the strip or the panel onto a day of the grid to swap it in. // dragged from the strip or the panel onto a day of the grid to swap it in.
export function PlanPage() { export function PlanPage() {
const layout = useBreakpoint() const layout = useBreakpoint()
const settings = useHouseholdSettings() const settings = useHouseholdSettings()
const [week, setWeek] = useState<string | null>(null) const [params, setParams] = useSearchParams()
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date())) const chosen = params.get(WEEK_PARAM)
// The default week is chosen once, not on every render: the plan_draft
// time passing does not move the page to another week.
const [opened, setOpened] = useState<string | null>(null)
if (opened === null && settings.data) setOpened(defaultWeek(settings.data, new Date()))
const week = chosen ?? opened
const setWeek = (weekStart: string) => setParams({ [WEEK_PARAM]: weekStart }, { replace: true })
let content let content
if (settings.isError) { if (chosen !== null && !isMonday(chosen)) {
content = <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
} else if (settings.isError) {
content = ( content = (
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner> <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
) )
+29 -1
View File
@@ -1,5 +1,17 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts' import {
addDays,
dayAndDate,
dayOfMonth,
defaultWeek,
isMonday,
localNow,
untilLocalMidnight,
weekdayKicker,
weekdayName,
weekLabel,
weekStart,
} from './week.ts'
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York. // SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' } const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
@@ -72,4 +84,20 @@ describe('week helpers', () => {
expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat
expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00 expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00
}) })
it('says whether a date is a real Monday', () => {
expect(isMonday('2026-10-05')).toBe(true)
expect(isMonday('2026-10-07')).toBe(false)
expect(isMonday('2026-02-30')).toBe(false)
expect(isMonday('next week')).toBe(false)
})
it('counts the time to the next midnight in the household zone, not UTC', () => {
// 23:59:30.250 in New York is already 03:59 the next day in UTC.
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T03:59:30.250Z'))).toBe(29_750)
// At midnight itself the next is a day away.
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T04:00:00Z'))).toBe(24 * 60 * 60 * 1000)
// The same instant is 13:00 in Tokyo: eleven hours to go.
expect(untilLocalMidnight('Asia/Tokyo', new Date('2026-10-03T04:00:00Z'))).toBe(11 * 60 * 60 * 1000)
})
}) })
+26
View File
@@ -35,6 +35,15 @@ export function weekStart(date: string): string {
return addDays(date, -sinceMonday) return addDays(date, -sinceMonday)
} }
// isMonday says whether date is a real YYYY-MM-DD Monday.
export function isMonday(date: string): boolean {
try {
return weekStart(date) === date
} catch {
return false
}
}
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN". // weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
export function weekdayKicker(date: string): string { export function weekdayKicker(date: string): string {
return WEEKDAYS[midnightUTC(date).getUTCDay()]! return WEEKDAYS[midnightUTC(date).getUTCDay()]!
@@ -115,3 +124,20 @@ export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time) const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
return drafted ? addDays(thisWeek, 7) : thisWeek return drafted ? addDays(thisWeek, 7) : thisWeek
} }
// untilLocalMidnight is how many milliseconds after instant now the next
// midnight comes in IANA zone. It counts from the zone's wall clock at now,
// so a change of UTC offset before that midnight moves it by the change;
// offsets change in the small hours, after midnight.
export function untilLocalMidnight(zone: string, now: Date): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(now)
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value)
const sinceMidnight = ((part('hour') * 60 + part('minute')) * 60 + part('second')) * 1000 + now.getUTCMilliseconds()
return DAY_MS - sinceMidnight
}
+1 -10
View File
@@ -1,7 +1,7 @@
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react' import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { Link, useSearchParams } from 'react-router' import { Link, useSearchParams } from 'react-router'
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts' import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
import { ApiError } from '../api/errors.ts' import { ApiError } from '../api/errors.ts'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
@@ -310,12 +310,3 @@ function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
const week = chosen ?? defaultWeek(schedule, new Date()) const week = chosen ?? defaultWeek(schedule, new Date())
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} /> return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
} }
// isMonday says whether date is a real YYYY-MM-DD Monday.
function isMonday(date: string): boolean {
try {
return weekOf(date) === date
} catch {
return false
}
}
+1
View File
@@ -66,6 +66,7 @@ export const GOOGLE_NOT_CONNECTED = {
// unread update. // unread update.
export const HOME = { export const HOME = {
dateLabel: 'Friday, Oct 2', dateLabel: 'Friday, Oct 2',
timezone: 'America/New_York',
tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 }, tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 },
sideQuestCount: 10, sideQuestCount: 10,
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null }, shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },