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((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' diff --git a/web/src/home/RotationCheck.tsx b/web/src/home/RotationCheck.tsx index 27af1b9..e2b0aef 100644 --- a/web/src/home/RotationCheck.tsx +++ b/web/src/home/RotationCheck.tsx @@ -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 ( - void reviews.refetch()}> - Couldn't load the Rotation check. {reviews.error.message} - - ) - } 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]}

)} + {reviews.isError && ( + void reviews.refetch()}> + Couldn't load the Rotation check. {reviews.error.message} + + )} {decide.isError && Couldn't save that answer. {decide.error.message}} {next !== undefined && ( { }) 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) => { diff --git a/web/src/home/api.test.ts b/web/src/home/api.test.ts index d4321c7..0810ff6 100644 --- a/web/src/home/api.test.ts +++ b/web/src/home/api.test.ts @@ -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', + ) + }) +}) diff --git a/web/src/home/api.ts b/web/src/home/api.ts index 71625f5..b466608 100644 --- a/web/src/home/api.ts +++ b/web/src/home/api.ts @@ -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(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 diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index fe90d5f..77c9867 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -160,16 +160,25 @@ function planReads(fetchMock: Mock) { } // 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 = ) { +// the Plan page at entry, over queryClient, with shopping as the Supply run +// screen. +async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = , entry = '/plan') { const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() return render( - + - } /> + + + + + } + /> @@ -190,6 +199,11 @@ function Opened() { ) } +// Searched shows the Plan's search, the week it keeps in ?week=. +function Searched() { + return

{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(), , '/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(), , '/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')), diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 7f3265f..d15f80f 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -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(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(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 = {`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`} + } else if (settings.isError) { content = ( void settings.refetch()}>Couldn't load the household settings. {settings.error.message} ) diff --git a/web/src/plan/week.test.ts b/web/src/plan/week.test.ts index d3ad06d..293aa23 100644 --- a/web/src/plan/week.test.ts +++ b/web/src/plan/week.test.ts @@ -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) + }) }) diff --git a/web/src/plan/week.ts b/web/src/plan/week.ts index b2c82d0..94ed7e0 100644 --- a/web/src/plan/week.ts +++ b/web/src/plan/week.ts @@ -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 +} diff --git a/web/src/shopping/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx index 839e8e2..7c889b8 100644 --- a/web/src/shopping/SupplyRunPage.tsx +++ b/web/src/shopping/SupplyRunPage.tsx @@ -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 } - -// isMonday says whether date is a real YYYY-MM-DD Monday. -function isMonday(date: string): boolean { - try { - return weekOf(date) === date - } catch { - return false - } -} diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index caeaaa6..febea05 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -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 },