From 832e6b9f7d44b56f6d65bd06807ab2e6e08d4799 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 10 Oct 2026 22:10:15 -0400 Subject: [PATCH 1/2] Document and test every route's empty, loading and error states docs/STATES.md lists each handoff route and the added Settings screens with its empty, loading and error state, all built on Skeleton, ErrorBanner and EmptyState. web/src/states.test.tsx renders the three variants of every route against a stubbed API, asserting the copy and pressing Retry. Task: 322a15db-b2ad-46f4-a2ba-cb3745dd0aff Co-Authored-By: Claude Opus 5.5 --- docs/STATES.md | 48 ++++ web/src/states.test.tsx | 547 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 595 insertions(+) create mode 100644 docs/STATES.md create mode 100644 web/src/states.test.tsx diff --git a/docs/STATES.md b/docs/STATES.md new file mode 100644 index 0000000..75a9e7f --- /dev/null +++ b/docs/STATES.md @@ -0,0 +1,48 @@ +# Empty, loading and error states + +Every route the PWA serves — the handoff's routes plus the added Settings +screens — with its empty, loading and error state. The handoff does not mock +these states; it gives one pattern for each +([handoff README](../design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md), +"Empty / loading / error states"), and every screen uses the shared component +for it: + +| State | Component | Pattern | +| --- | --- | --- | +| Loading | `Skeleton` ([web/src/ui/Skeleton.tsx](../web/src/ui/Skeleton.tsx)) | Pill-shaped skeletons in `--color-surface` with a 1.2 s opacity pulse (no pulse under reduced motion). | +| Error | `ErrorBanner` ([web/src/ui/ErrorBanner.tsx](../web/src/ui/ErrorBanner.tsx)) | `--color-neutral-100` card, grape-700 (`--color-accent-700`) text, and a **Retry** button that reads or asks again. | +| Empty | `EmptyState` ([web/src/ui/EmptyState.tsx](../web/src/ui/EmptyState.tsx)) | Bagel title, body text and an optional primary action — the Discover deck's "All caught up / New side quests arrive Sunday." with "Get more now" is the model. | + +[web/src/states.test.tsx](../web/src/states.test.tsx) renders the loading, +empty and error variant of each route below against a stubbed API, asserting +the copy and pressing Retry. Each screen's own test file covers its other +states. + +The loading and error rows name the screen's main read. Smaller reads on a +screen (a Quest log count, a calendar list, a webhook card) use the same +`Skeleton` and `ErrorBanner` in place. + +## Handoff routes + +| Route | Screen | Empty | Loading | Error | +| --- | --- | --- | --- | --- | +| `/` | Home | `EmptyState` "No quest planned tonight" with **Plan this week**; rows that do not apply are hidden. | Skeletons for the date, Tonight card and status rows. | "Couldn't load tonight's quest. …" + Retry. | +| `/onboarding` | Onboarding — Regulars step | "Your regulars · 0" with the Common picks to add; **Next: notifications** stays disabled until enough Regulars are saved. | Skeleton under "Your regulars". | "Your Regulars could not load: …" + Retry. | +| `/plan` | Weekly plan (Draft board) | `EmptyState` "Nothing planned yet" with **Draft this week**. | Seven skeleton tiles ("Plan loading"; "Drafting this week" while the AI drafts). | "Couldn't load this week's plan. …" + Retry. | +| `/plan/shopping` | Supply run | `EmptyState` "No shopping list yet" with **Make the shopping list**; an archived week with none shows "No shopping list". A list with no ingredients shows "Nothing to buy". | Skeleton title and dinner cards. | "Couldn't load the shopping list. …" + Retry. | +| `/plan/shopping/archive` | Supply archive | `EmptyState` "Nothing archived yet" — "Each week's Supply run becomes a post here once it's made." | Skeleton posts. | "Couldn't load the Supply archive. …" + Retry. | +| `/discover` | Side quests deck | `EmptyState` **"All caught up"** — "New side quests arrive Sunday." with **Get more now**. | Skeleton card stack (also while a batch finds more). | "Couldn't load your side quests. …" + Retry. | +| `/quick` | Quick dinners | No ideas found: "Couldn't find Quick dinners. No ideas came back with a recipe to match." + Retry (an `ErrorBanner`, as no idea was found to show). | Six skeleton rows while the tab's ideas are found. | "Couldn't start looking for Quick dinners. …" + Retry. | +| `/cook-from-pantry` | Cook from what I have | Before a search: no ingredient chips and **Find ideas** disabled. No ideas found: "Couldn't find ideas. No ideas came back with a recipe to match." + Retry. | Five skeleton cards ("Ideas loading"). | "Couldn't start looking for ideas. …" + Retry. | +| `/dinner/:id` | Dinner detail | `EmptyState` "Dinner not found" — "This dinner isn't in our kitchen — it may have been removed." | Skeleton hero, title and lines. | "Couldn't load this dinner. …" + Retry. | +| `/settings` | Settings home | Status lines "None on rotation yet", "Not connected", "Push off · Google Chat not connected". | A skeleton per status line. | "Settings could not load: …" + Retry, which reads every failed status again. | +| `/settings/notifications` | Notifications & Google Chat | No webhook stored: the webhook form with **Save webhook**. | Skeletons for the channel matrix and the webhook card. | "Notification settings could not load: …" + Retry ("The Google Chat webhook could not load: …" for the webhook card). A failed webhook test says "Test message not sent: …" under the form. | +| `/settings/ai` | Admin · AI assistant | No key stored: "No key stored" with **Add key**. | Skeleton. | "AI settings could not load: …" + Retry. | + +## Added Settings screens + +| Route | Screen | Empty | Loading | Error | +| --- | --- | --- | --- | --- | +| `/settings/household` | Household | None: the household's settings always exist, seeded on first start; the screen shows the seeded values. | Skeleton. | "Household settings could not load: …" + Retry. | +| `/settings/regulars` | Regular dinners | "On rotation · 0" — "No Regulars yet. Add the dinners your household makes on repeat." | Skeleton. | "Your Regulars could not load: …" + Retry. | +| `/settings/calendar` | Google Calendar | "Not connected" with **Connect Google Calendar**. | Skeleton (and one for the calendar picker). | "Google Calendar status could not load: …" + Retry ("Your calendars could not load: …" + Retry for the picker). | diff --git a/web/src/states.test.tsx b/web/src/states.test.tsx new file mode 100644 index 0000000..70158f6 --- /dev/null +++ b/web/src/states.test.tsx @@ -0,0 +1,547 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import type { ReactNode } from 'react' +import { Route, Routes } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from './api/index.ts' +import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx' +import { DiscoverPage } from './discover/DiscoverPage.tsx' +import { HomePage } from './home/HomePage.tsx' +import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx' +import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx' +import { OnboardingPage } from './onboarding/OnboardingPage.tsx' +import { createNetworkStatus } from './pwa/networkStatus.ts' +import { PlanPage } from './plan/PlanPage.tsx' +import { defaultWeek } from './plan/week.ts' +import { AISettingsPage } from './settings/AISettingsPage.tsx' +import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx' +import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx' +import { NotificationsPage } from './settings/NotificationsPage.tsx' +import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' +import { SettingsPage } from './settings/SettingsPage.tsx' +import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx' +import { SupplyRunPage } from './shopping/SupplyRunPage.tsx' +import { + AI_MODELS, + AI_SETTINGS, + CHAT_WEBHOOK_NONE, + GOOGLE_NOT_CONNECTED, + HOME, + HOUSEHOLD_SETTINGS, + NOTIFICATION_SETTINGS, + RECIPE_SEARCH_SETTINGS, + SETTINGS_API, +} from './test/ApiProviders.tsx' +import { InDataRouter } from './test/dataRouter.tsx' +import { dinnerBody } from './test/dinners.ts' +import { errorResponse, fakeLocation, jsonResponse, ME } from './test/fetchStub.ts' +import { mockViewport } from './test/matchMedia.ts' +import skeletonStyles from './ui/Skeleton.module.css' + +// The empty, loading and error state of every route, as docs/STATES.md +// lists them: each route renders its loading, empty and error variants +// against a stubbed API. + +type Responder = () => Response | Promise + +// pending is a response that never comes, so the page stays loading. +const pending: Responder = () => new Promise(() => {}) + +// failThen answers the first request with a 500, and every later one with +// then: Retry reads again and gets it. +function failThen(then: Responder): Responder { + let failed = false + return () => { + if (failed) return then() + failed = true + return errorResponse(500, 'internal') + } +} + +const json = (body: unknown): Responder => () => jsonResponse(200, body) + +// startJobs answers each POST that starts a job with the next job id, j1, +// j2 and so on. +function startJobs(): Responder { + let n = 0 + return () => jsonResponse(202, { jobId: `j${++n}` }) +} + +// serve answers "METHOD path" requests from responders, with /me and the +// household settings; any other request fails the test. +function serve(responders: Record) { + const all: Record = { + 'GET /api/v1/me': json(ME), + 'GET /api/v1/settings/household': json(HOUSEHOLD_SETTINGS), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond()) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// renderRoute renders element on its route pattern at path, under a data +// router as App renders it, once /me has loaded as AuthBootstrap loads it. +async function renderRoute(path: string, pattern: string, element: ReactNode) { + const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + return render( + + + + + + + + + , + ) +} + +function skeletons() { + return document.querySelectorAll(`.${skeletonStyles.skeleton}`) +} + +// expectLoading waits for the pill skeletons and checks nothing failed. +async function expectLoading() { + await waitFor(() => expect(skeletons().length).toBeGreaterThan(0)) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +} + +// expectRetry finds the error banner saying said, presses its Retry and +// waits for recovered. +async function expectRetry(said: string, recovered: () => Promise) { + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent(said) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await recovered() + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) +} + +const WEEK = () => defaultWeek(HOUSEHOLD_SETTINGS, new Date()) + +beforeEach(() => { + mockViewport(390) +}) + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() +}) + +describe('/ Home', () => { + const NOTHING_TONIGHT = { ...HOME, tonight: null, sideQuestCount: 0, recentUpdates: { unread: false } } + const page = async (home: Responder) => { + serve({ 'GET /api/v1/home': home, 'GET /api/v1/rotation-reviews': json({ reviews: [] }) }) + await renderRoute('/', '/', ) + } + + it('loading: skeletons', async () => { + await page(pending) + await expectLoading() + }) + + it('empty: no quest planned tonight, with Plan this week', async () => { + await page(json(NOTHING_TONIGHT)) + expect(await screen.findByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Plan this week' })).toBeInTheDocument() + }) + + it('error: banner with Retry', async () => { + await page(failThen(json(NOTHING_TONIGHT))) + await expectRetry("Couldn't load tonight's quest. internal message", () => + screen.findByRole('heading', { name: 'No quest planned tonight' }), + ) + }) +}) + +describe('/onboarding Regulars step', () => { + const page = async (regulars: Responder) => { + serve({ 'GET /api/v1/dinners?status=regular': regulars }) + await renderRoute('/onboarding/regulars', '/onboarding/*', ) + } + + it('loading: skeleton', async () => { + await page(pending) + await expectLoading() + }) + + it('empty: no Regulars yet, common picks offered and Next disabled', async () => { + await page(json({ dinners: [] })) + expect(await screen.findByRole('heading', { name: 'Common picks' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Your regulars · 0' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next: notifications' })).toBeDisabled() + }) + + it('error: banner with Retry', async () => { + await page(failThen(json({ dinners: [] }))) + await expectRetry('Your Regulars could not load: internal message', () => + screen.findByRole('heading', { name: 'Common picks' }), + ) + }) +}) + +describe('/plan Draft board', () => { + const page = async (plan: Responder) => { + serve({ [`GET /api/v1/plans/${WEEK()}`]: plan, [`GET /api/v1/shopping/${WEEK()}`]: () => errorResponse(404, 'not_found') }) + await renderRoute('/plan', '/plan', ) + } + const noPlan = () => errorResponse(404, 'not_found') + + it('loading: skeleton tiles', async () => { + await page(pending) + expect(await screen.findByRole('region', { name: 'Plan loading' })).toBeInTheDocument() + await expectLoading() + }) + + it('empty: nothing planned yet, with Draft this week', async () => { + await page(noPlan) + expect(await screen.findByRole('heading', { name: 'Nothing planned yet' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + }) + + it('error: banner with Retry', async () => { + await page(failThen(noPlan)) + await expectRetry("Couldn't load this week's plan. internal message", () => + screen.findByRole('heading', { name: 'Nothing planned yet' }), + ) + }) +}) + +describe('/plan/shopping Supply run', () => { + const page = async (run: Responder) => { + serve({ [`GET /api/v1/shopping/${WEEK()}`]: run }) + await renderRoute('/plan/shopping', '/plan/shopping', ) + } + const noList = () => errorResponse(404, 'not_found') + + it('loading: skeleton cards', async () => { + await page(pending) + await expectLoading() + }) + + it('empty: no shopping list yet, with Make the shopping list', async () => { + await page(noList) + expect(await screen.findByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Make the shopping list' })).toBeInTheDocument() + }) + + it('error: banner with Retry', async () => { + await page(failThen(noList)) + await expectRetry("Couldn't load the shopping list. internal message", () => + screen.findByRole('heading', { name: 'No shopping list yet' }), + ) + }) +}) + +describe('/plan/shopping/archive Supply archive', () => { + const page = async (archive: Responder) => { + serve({ 'GET /api/v1/shopping/archive?by=week': archive }) + await renderRoute('/plan/shopping/archive', '/plan/shopping/archive', ) + } + const nothing = json({ by: 'week', periods: [] }) + + it('loading: skeleton posts', async () => { + await page(pending) + await expectLoading() + }) + + it('empty: nothing archived yet', async () => { + await page(nothing) + expect(await screen.findByRole('heading', { name: 'Nothing archived yet' })).toBeInTheDocument() + expect(screen.getByText("Each week's Supply run becomes a post here once it's made.")).toBeInTheDocument() + }) + + it('error: banner with Retry', async () => { + await page(failThen(nothing)) + await expectRetry("Couldn't load the Supply archive. internal message", () => + screen.findByRole('heading', { name: 'Nothing archived yet' }), + ) + }) +}) + +describe('/discover Side quests', () => { + const page = async (deck: Responder) => { + serve({ 'GET /api/v1/suggestions/deck': deck, 'GET /api/v1/dinners?status=quest_log': json({ dinners: [] }) }) + await renderRoute('/discover', '/discover', ) + } + const empty = json({ suggestions: [], active_batch: null, failed_batch: null }) + + it('loading: skeleton cards', async () => { + await page(pending) + await expectLoading() + }) + + it('empty: All caught up / New side quests arrive Sunday., with Get more now', async () => { + await page(empty) + expect(await screen.findByRole('heading', { name: 'All caught up' })).toBeInTheDocument() + expect(screen.getByText('New side quests arrive Sunday.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Get more now' })).toBeInTheDocument() + }) + + it('error: banner with Retry', async () => { + await page(failThen(empty)) + await expectRetry("Couldn't load your side quests. internal message", () => + screen.findByRole('heading', { name: 'All caught up' }), + ) + }) +}) + +describe('/quick Quick dinners', () => { + const QUICK = 'POST /api/v1/ideas/quick' + const job = (status: string, error: string | null) => ({ + id: 'j1', + kind: 'quick_ideas', + status, + result: { stage: 'grounding', kind: 'under30', exclude: [], ideas: [], dropped: 0 }, + error, + }) + const page = async (quick: Responder, poll: Responder = pending) => { + serve({ [QUICK]: quick, 'GET /api/v1/jobs/j1': poll, 'GET /api/v1/jobs/j2': pending }) + await renderRoute('/quick', '/quick', ) + } + + it('loading: skeleton rows', async () => { + await page(pending) + await waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6)) + await expectLoading() + }) + + it('empty: no ideas found, with Retry', async () => { + await page(startJobs(), json(job('failed', 'no_ideas_found'))) + await expectRetry("Couldn't find Quick dinners. No ideas came back with a recipe to match.", () => + waitFor(() => expect(skeletons().length).toBeGreaterThan(0)), + ) + }) + + it('error: banner with Retry', async () => { + await page(failThen(pending)) + await expectRetry("Couldn't start looking for Quick dinners. internal message", () => + waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6)), + ) + }) +}) + +describe('/cook-from-pantry Cook from what I have', () => { + const FIND = 'POST /api/v1/ideas/from-ingredients' + const job = { + id: 'j1', + kind: 'ideas_from_ingredients', + status: 'failed', + result: { stage: 'grounding', ingredients: ['Rice'], quick_only: false, ideas: [], dropped: 0 }, + error: 'no_ideas_found', + } + const page = async (find: Responder, poll: Responder = pending) => { + serve({ [FIND]: find, 'GET /api/v1/jobs/j1': poll, 'GET /api/v1/jobs/j2': pending }) + await renderRoute('/cook-from-pantry', '/cook-from-pantry', ) + } + const search = () => { + fireEvent.change(screen.getByRole('textbox', { name: 'Add ingredient' }), { target: { value: 'Rice' } }) + fireEvent.click(screen.getByRole('button', { name: 'Find ideas' })) + } + + it('loading: skeleton cards', async () => { + await page(pending) + search() + expect(await screen.findByRole('region', { name: 'Ideas loading' })).toBeInTheDocument() + await expectLoading() + }) + + it('empty: no ingredients yet, with Find ideas disabled; no ideas found, with Retry', async () => { + await page(startJobs(), json(job)) + expect(screen.getByRole('button', { name: 'Find ideas' })).toBeDisabled() + search() + await expectRetry("Couldn't find ideas. No ideas came back with a recipe to match.", () => + screen.findByRole('region', { name: 'Ideas' }), + ) + }) + + it('error: banner with Retry', async () => { + await page(failThen(pending)) + search() + await expectRetry("Couldn't start looking for ideas. internal message", () => + screen.findByRole('region', { name: 'Ideas loading' }), + ) + }) +}) + +describe('/dinner/:id Dinner', () => { + const page = async (dinner: Responder) => { + serve({ 'GET /api/v1/dinners/d1': dinner }) + await renderRoute('/dinner/d1', '/dinner/:id', ) + } + const notFound = () => errorResponse(404, 'not_found') + + it('loading: skeletons', async () => { + await page(pending) + await expectLoading() + }) + + it('empty: dinner not found', async () => { + await page(notFound) + expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument() + }) + + it('error: banner with Retry', async () => { + await page(failThen(notFound)) + await expectRetry("Couldn't load this dinner. internal message", () => + screen.findByRole('heading', { name: 'Dinner not found' }), + ) + }) +}) + +describe('/settings Settings home', () => { + const page = async (calendar: Responder) => { + const api = Object.fromEntries(Object.entries(SETTINGS_API).map(([path, respond]) => [`GET ${path}`, respond])) + serve({ ...api, 'GET /api/v1/dinners?status=regular': json({ dinners: [] }), 'GET /api/v1/integrations/google': calendar }) + await renderRoute('/settings', '/settings', ) + } + const notConnected = json(GOOGLE_NOT_CONNECTED) + + it('loading: status skeletons', async () => { + await page(pending) + await expectLoading() + }) + + it('empty: no Regulars, calendar not connected, Google Chat not connected', async () => { + await page(notConnected) + expect(await screen.findByText('None on rotation yet')).toBeInTheDocument() + expect(await screen.findByText('Not connected')).toBeInTheDocument() + expect(await screen.findByText(/Google Chat not connected/)).toBeInTheDocument() + }) + + it('error: banner with Retry', async () => { + await page(failThen(notConnected)) + await expectRetry('Settings could not load: internal message', () => screen.findByText('Not connected')) + }) +}) + +describe('/settings/notifications Notifications', () => { + const page = async (prefs: Responder) => { + serve({ 'GET /api/v1/settings/notifications': prefs, 'GET /api/v1/settings/chat-webhook': json(CHAT_WEBHOOK_NONE) }) + await renderRoute('/settings/notifications', '/settings/notifications', ) + } + + it('loading: skeleton', async () => { + await page(pending) + await expectLoading() + }) + + it('empty: no Google Chat webhook stored', async () => { + await page(json(NOTIFICATION_SETTINGS)) + expect(await screen.findByRole('button', { name: 'Save webhook' })).toBeInTheDocument() + }) + + it('error: banner with Retry', async () => { + await page(failThen(json(NOTIFICATION_SETTINGS))) + await expectRetry('Notification settings could not load: internal message', () => + screen.findByRole('group', { name: 'Notification channels' }), + ) + }) +}) + +describe('/settings/ai AI assistant', () => { + const NO_KEY = { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null } + const page = async (settings: Responder) => { + serve({ + 'GET /api/v1/settings/ai': settings, + 'GET /api/v1/settings/ai/models': json(AI_MODELS), + 'GET /api/v1/settings/recipe-search': json(RECIPE_SEARCH_SETTINGS), + }) + await renderRoute('/settings/ai', '/settings/ai', ) + } + + it('loading: skeleton', async () => { + await page(pending) + await expectLoading() + }) + + it('empty: no key stored, with Add key', async () => { + await page(json(NO_KEY)) + expect(await screen.findByText('No key stored')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add key' })).toBeInTheDocument() + }) + + it('error: banner with Retry', async () => { + await page(failThen(json(NO_KEY))) + await expectRetry('AI settings could not load: internal message', () => screen.findByText('No key stored')) + }) +}) + +describe('/settings/household Household', () => { + const page = async (settings: Responder) => { + serve({ 'GET /api/v1/settings/household': settings }) + await renderRoute('/settings/household', '/settings/household', ) + } + + it('loading: skeleton', async () => { + await page(pending) + await expectLoading() + }) + + // The household's settings always exist (seeded on first start), so its + // "empty" state is the seeded defaults. + it('empty: the seeded settings', async () => { + await page(json(HOUSEHOLD_SETTINGS)) + expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30') + }) + + it('error: banner with Retry', async () => { + await page(failThen(json(HOUSEHOLD_SETTINGS))) + await expectRetry('Household settings could not load: internal message', () => screen.findByLabelText('Dinner time')) + }) +}) + +describe('/settings/regulars Regular dinners', () => { + const page = async (regulars: Responder) => { + serve({ 'GET /api/v1/dinners?status=regular': regulars }) + await renderRoute('/settings/regulars', '/settings/regulars', ) + } + const none = json({ dinners: [] }) + + it('loading: skeleton', async () => { + await page(pending) + await expectLoading() + }) + + it('empty: no Regulars yet', async () => { + await page(none) + expect(await screen.findByText('No Regulars yet. Add the dinners your household makes on repeat.')).toBeInTheDocument() + }) + + it('error: banner with Retry', async () => { + await page(failThen(json({ dinners: [dinnerBody('d1', 'Beef tacos')] }))) + await expectRetry('Your Regulars could not load: internal message', () => screen.findByText('Beef tacos')) + }) +}) + +describe('/settings/calendar Calendar', () => { + const page = async (connection: Responder) => { + serve({ 'GET /api/v1/integrations/google': connection }) + await renderRoute('/settings/calendar', '/settings/calendar', ) + } + const notConnected = json(GOOGLE_NOT_CONNECTED) + + it('loading: skeleton', async () => { + await page(pending) + await expectLoading() + }) + + it('empty: not connected, with Connect Google Calendar', async () => { + await page(notConnected) + expect(await screen.findByRole('heading', { name: 'Not connected' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument() + }) + + it('error: banner with Retry', async () => { + await page(failThen(notConnected)) + await expectRetry('Google Calendar status could not load: internal message', () => + screen.findByRole('heading', { name: 'Not connected' }), + ) + }) +}) -- 2.55.0 From 9a183c1984aa8e7385f6ac81288b3f0c62acd58a Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 10 Oct 2026 22:35:40 -0400 Subject: [PATCH 2/2] Scope settings loading tests to their section and document onboarding paths The /settings/notifications and /settings/ai loading tests passed on any skeleton, including the webhook and Recipe search cards' own. They now wait for those sections to settle and check the one skeleton left sits before them, so removing the held section's skeleton fails the test. docs/STATES.md listed /onboarding as the Regulars step. It now lists the Welcome (/onboarding), Regulars (/onboarding/regulars) and Notifications (/onboarding/notifications) steps, each tested at its own path. Co-Authored-By: Claude Opus 5.5 --- docs/STATES.md | 4 ++- web/src/states.test.tsx | 64 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/docs/STATES.md b/docs/STATES.md index 75a9e7f..fa1a1f8 100644 --- a/docs/STATES.md +++ b/docs/STATES.md @@ -27,7 +27,9 @@ screen (a Quest log count, a calendar list, a webhook card) use the same | Route | Screen | Empty | Loading | Error | | --- | --- | --- | --- | --- | | `/` | Home | `EmptyState` "No quest planned tonight" with **Plan this week**; rows that do not apply are hidden. | Skeletons for the date, Tonight card and status rows. | "Couldn't load tonight's quest. …" + Retry. | -| `/onboarding` | Onboarding — Regulars step | "Your regulars · 0" with the Common picks to add; **Next: notifications** stays disabled until enough Regulars are saved. | Skeleton under "Your regulars". | "Your Regulars could not load: …" + Retry. | +| `/onboarding` | Onboarding — Welcome step | None: the step reads nothing; it shows the welcome and the Regulars, Side quests and Quest log terms with **Let's start**. | None: nothing to read. | None: nothing to read. | +| `/onboarding/regulars` | Onboarding — Regulars step | "Your regulars · 0" with the Common picks to add; **Next: notifications** stays disabled until enough Regulars are saved. | Skeleton under "Your regulars". | "Your Regulars could not load: …" + Retry. | +| `/onboarding/notifications` | Onboarding — Notifications step | None: the step reads nothing. | While finishing, **Turn on notifications** and **Maybe later** are disabled. | "Could not finish setting up: …"; the buttons come back to try again, with **Add Regulars** when fewer than three Regulars are saved. | | `/plan` | Weekly plan (Draft board) | `EmptyState` "Nothing planned yet" with **Draft this week**. | Seven skeleton tiles ("Plan loading"; "Drafting this week" while the AI drafts). | "Couldn't load this week's plan. …" + Retry. | | `/plan/shopping` | Supply run | `EmptyState` "No shopping list yet" with **Make the shopping list**; an archived week with none shows "No shopping list". A list with no ingredients shows "Nothing to buy". | Skeleton title and dinner cards. | "Couldn't load the shopping list. …" + Retry. | | `/plan/shopping/archive` | Supply archive | `EmptyState` "Nothing archived yet" — "Each week's Supply run becomes a post here once it's made." | Skeleton posts. | "Couldn't load the Supply archive. …" + Retry. | diff --git a/web/src/states.test.tsx b/web/src/states.test.tsx index 70158f6..7ccecdb 100644 --- a/web/src/states.test.tsx +++ b/web/src/states.test.tsx @@ -113,6 +113,18 @@ async function expectLoading() { expect(screen.queryByRole('alert')).not.toBeInTheDocument() } +// expectOnlyLoadingBefore checks, once a page's independent sections have +// settled, that the one skeleton left is the held read's: it sits before +// next, the first settled section after it, and nothing failed. +function expectOnlyLoadingBefore(next: Element) { + expect(skeletons()).toHaveLength(1) + const left = skeletons().item(0) + if (left === null) throw new Error('no skeleton left') + expect(next.contains(left)).toBe(false) + expect(left.compareDocumentPosition(next) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() +} + // expectRetry finds the error banner saying said, presses its Retry and // waits for recovered. async function expectRetry(said: string, recovered: () => Promise) { @@ -160,7 +172,18 @@ describe('/ Home', () => { }) }) -describe('/onboarding Regulars step', () => { +describe('/onboarding Welcome step', () => { + it('reads nothing, so it has no empty, loading or error state', async () => { + const fetchMock = serve({}) + await renderRoute('/onboarding', '/onboarding/*', ) + expect(screen.getByRole('heading', { name: 'Welcome to MealQuest' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: "Let's start" })).toBeEnabled() + expect(skeletons()).toHaveLength(0) + expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual(['/api/v1/me']) + }) +}) + +describe('/onboarding/regulars Regulars step', () => { const page = async (regulars: Responder) => { serve({ 'GET /api/v1/dinners?status=regular': regulars }) await renderRoute('/onboarding/regulars', '/onboarding/*', ) @@ -186,6 +209,27 @@ describe('/onboarding Regulars step', () => { }) }) +describe('/onboarding/notifications Notifications step', () => { + const page = async (finish: Responder) => { + serve({ 'PATCH /api/v1/settings/household': finish }) + await renderRoute('/onboarding/notifications', '/onboarding/*', ) + fireEvent.click(screen.getByRole('button', { name: 'Maybe later' })) + } + + it("loading: the step's buttons wait while it finishes", async () => { + await page(pending) + await waitFor(() => expect(screen.getByRole('button', { name: 'Maybe later' })).toBeDisabled()) + expect(screen.getByRole('button', { name: 'Turn on notifications' })).toBeDisabled() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('error: says finishing failed and lets it be tried again', async () => { + await page(failThen(pending)) + expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message') + expect(screen.getByRole('button', { name: 'Maybe later' })).toBeEnabled() + }) +}) + describe('/plan Draft board', () => { const page = async (plan: Responder) => { serve({ [`GET /api/v1/plans/${WEEK()}`]: plan, [`GET /api/v1/shopping/${WEEK()}`]: () => errorResponse(404, 'not_found') }) @@ -427,9 +471,15 @@ describe('/settings/notifications Notifications', () => { await renderRoute('/settings/notifications', '/settings/notifications', ) } - it('loading: skeleton', async () => { + it('loading: skeleton for the channel matrix', async () => { await page(pending) - await expectLoading() + // The webhook card and this device's push status read on their own; + // once they settle, the one skeleton left is the channel matrix's. + await screen.findByRole('button', { name: 'Save webhook' }) + const device = screen.getByRole('group', { name: 'Notifications on this device' }) + await waitFor(() => expect(device.querySelectorAll(`.${skeletonStyles.skeleton}`)).toHaveLength(0)) + expectOnlyLoadingBefore(device) + expect(screen.queryByRole('group', { name: 'Notification channels' })).not.toBeInTheDocument() }) it('empty: no Google Chat webhook stored', async () => { @@ -456,9 +506,13 @@ describe('/settings/ai AI assistant', () => { await renderRoute('/settings/ai', '/settings/ai', ) } - it('loading: skeleton', async () => { + it('loading: skeleton for the AI assistant form', async () => { await page(pending) - await expectLoading() + // The Recipe search card reads on its own; once it settles, the one + // skeleton left is the AI assistant form's. + await screen.findByRole('button', { name: 'Save recipe search' }) + expectOnlyLoadingBefore(screen.getByRole('region', { name: 'Recipe search' })) + expect(screen.queryByText('No key stored')).not.toBeInTheDocument() }) it('empty: no key stored, with Add key', async () => { -- 2.55.0