Fix Home dashboard integration findings (Feature 21)
- 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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<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 () => {
|
||||
stubApi([SHAKSHUKA])
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
@@ -44,17 +44,12 @@ function RotationCard({ review, disabled, onDecide }: RotationCardProps) {
|
||||
// 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
|
||||
// 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() {
|
||||
const reviews = useRotationReviews()
|
||||
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 next = reviews.data?.find((review) => review.dinner.id !== deciding)
|
||||
const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined
|
||||
@@ -65,6 +60,11 @@ export function RotationCheck() {
|
||||
{CONFIRMATION[answered.decision]}
|
||||
</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>}
|
||||
{next !== undefined && (
|
||||
<RotationCard
|
||||
|
||||
@@ -28,7 +28,7 @@ const UPDATES = {
|
||||
type: 'plan_ready',
|
||||
title: 'Your week is drafted',
|
||||
body: 'Take a look at the Draft board.',
|
||||
url: '/plan',
|
||||
url: '/plan?week=2026-10-12',
|
||||
created_at: minutesAgo(5),
|
||||
},
|
||||
{ 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([
|
||||
[0, '/plan'],
|
||||
[0, '/plan?week=2026-10-12'],
|
||||
[1, '/discover'],
|
||||
[2, '/plan/shopping?week=2026-10-05'],
|
||||
])('row %i goes to %s and closes the sheet', async (i, to) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 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
@@ -1,8 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.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'
|
||||
|
||||
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 }
|
||||
|
||||
// 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
|
||||
// something not yet seen.
|
||||
export type Home = {
|
||||
dateLabel: string
|
||||
timezone: string
|
||||
tonight: Tonight | null
|
||||
sideQuestCount: number
|
||||
shoppingList: ShoppingListStatus
|
||||
@@ -77,6 +80,9 @@ function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
|
||||
// parseHome checks a GET /api/v1/home body.
|
||||
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 (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) {
|
||||
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`)
|
||||
return {
|
||||
dateLabel: body.dateLabel,
|
||||
timezone: body.timezone,
|
||||
tonight: parseTonight(where, body.tonight),
|
||||
sideQuestCount: body.sideQuestCount as number,
|
||||
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() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
const queryClient = useQueryClient()
|
||||
const home = useQuery({
|
||||
queryKey: HOME_KEY,
|
||||
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
|
||||
|
||||
@@ -160,16 +160,25 @@ function planReads(fetchMock: Mock<typeof fetch>) {
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Plan page, over queryClient, with shopping as the Supply run screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
|
||||
// the Plan page at entry, over queryClient, with shopping as the Supply run
|
||||
// screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />, entry = '/plan') {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route
|
||||
path="/plan"
|
||||
element={
|
||||
<>
|
||||
<PlanPage />
|
||||
<Searched />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route path="/plan/shopping" element={shopping} />
|
||||
</Routes>
|
||||
</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
|
||||
// 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(), <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 () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-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 { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
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 { Button } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
addDays,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
isMonday,
|
||||
localNow,
|
||||
weekdayKicker,
|
||||
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
|
||||
// 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
|
||||
// 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. 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
|
||||
// dragged from the strip or the panel onto a day of the grid to swap it in.
|
||||
export function PlanPage() {
|
||||
const layout = useBreakpoint()
|
||||
const settings = useHouseholdSettings()
|
||||
const [week, setWeek] = useState<string | null>(null)
|
||||
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
|
||||
const [params, setParams] = useSearchParams()
|
||||
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
|
||||
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 = (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
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.
|
||||
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-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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,15 @@ export function weekStart(date: string): string {
|
||||
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".
|
||||
export function weekdayKicker(date: string): string {
|
||||
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)
|
||||
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,7 +1,7 @@
|
||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
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 { formatClockTime, useHouseholdSettings } from '../settings/api.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())
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export const GOOGLE_NOT_CONNECTED = {
|
||||
// unread update.
|
||||
export const HOME = {
|
||||
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 },
|
||||
sideQuestCount: 10,
|
||||
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },
|
||||
|
||||
Reference in New Issue
Block a user