Keep it in rotation card (Feature 21) #179
@@ -88,7 +88,7 @@ describe('App routes', () => {
|
||||
|
||||
it('/ renders the Home screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
|
||||
renderAt('/')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
@@ -273,7 +273,13 @@ describe('App bootstrap', () => {
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS), jsonResponse(200, HOME))
|
||||
const mes = [errorResponse(500, 'internal'), jsonResponse(200, ME)]
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/me': () => mes.shift()!,
|
||||
'/api/v1/home': () => jsonResponse(200, HOME),
|
||||
'/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }),
|
||||
})
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
@@ -294,7 +300,7 @@ describe('App offline bootstrap', () => {
|
||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||
// member, then the app is closed.
|
||||
async function visitOnline() {
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
|
||||
const { unmount } = renderAt('/')
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
@@ -3,10 +3,11 @@ import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, jsonResponse, stubFetch, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { HOME_PATH } from './api.ts'
|
||||
import { HomePage } from './HomePage.tsx'
|
||||
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
|
||||
|
||||
// NOTHING_YET is the home of a household with no plan, no Side quests, no
|
||||
// Supply run and nothing unread.
|
||||
@@ -29,7 +30,7 @@ function Where() {
|
||||
}
|
||||
|
||||
function renderHome(body: unknown) {
|
||||
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body) })
|
||||
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body), [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }) })
|
||||
return renderRouter()
|
||||
}
|
||||
|
||||
@@ -198,7 +199,11 @@ describe('HomePage', () => {
|
||||
})
|
||||
|
||||
it('reports a failure with Retry, and no made-up content', async () => {
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, HOME))
|
||||
const homes = [errorResponse(500, 'internal'), jsonResponse(200, HOME)]
|
||||
stubFetchByPath({
|
||||
[HOME_PATH]: () => homes.shift()!,
|
||||
[ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }),
|
||||
})
|
||||
renderRouter()
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message")
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Photo } from '../ui/Photo.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { useHome, type Home, type Origin, type Tonight } from './api.ts'
|
||||
import styles from './HomePage.module.css'
|
||||
import { RotationCheck } from './RotationCheck.tsx'
|
||||
import { UpdatesSheet } from './UpdatesSheet.tsx'
|
||||
|
||||
const ORIGIN_LABEL: Record<Origin, string> = { regular: 'Regular', side_quest: 'Side quest' }
|
||||
@@ -108,7 +109,8 @@ function Tiles() {
|
||||
// HomePage is mock 1g, Home: today's date over "Tonight's quest" and the
|
||||
// Recent updates bell, the Tonight card (or the way to plan the week), the
|
||||
// Quick dinner and Cook from what I have tiles, and the status rows, all
|
||||
// from GET /api/v1/home.
|
||||
// from GET /api/v1/home. Above the tiles is the Rotation check, "Keep it in
|
||||
// rotation?", while a tried dinner waits for the household's answer.
|
||||
//
|
||||
// The bell carries a grape dot while there are unread updates and opens the
|
||||
// Recent updates sheet, which marks them seen.
|
||||
@@ -141,6 +143,7 @@ export function HomePage() {
|
||||
<ErrorBanner onRetry={() => void home.refetch()}>Couldn't load tonight's quest. {home.error.message}</ErrorBanner>
|
||||
)}
|
||||
{data !== undefined && (data.tonight === null ? <NoPlan /> : <TonightCard tonight={data.tonight} />)}
|
||||
<RotationCheck />
|
||||
<Tiles />
|
||||
{home.isPending && (
|
||||
<div className={styles.statuses} aria-hidden="true">
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/* The Rotation check on Home: "Keep it in rotation?" above the tiles. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 32px;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 4px 18px 12px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.question {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* "We didn't make it": a text link at the 44 px tap minimum. */
|
||||
.notMade {
|
||||
align-self: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-accent-700);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notMade:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.notMade:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.confirmation {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
background: var(--color-accent-2-300);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOME } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { HOME_PATH } from './api.ts'
|
||||
import { HomePage } from './HomePage.tsx'
|
||||
import { rotationReviewPath, ROTATION_REVIEWS_PATH } from './rotation.ts'
|
||||
|
||||
// SHAKSHUKA and RAMEN are pending Rotation checks, oldest first, as GET
|
||||
// /api/v1/rotation-reviews sends them.
|
||||
const SHAKSHUKA = { dinner: { id: 'd7', name: 'Shakshuka', photo: '/media/photos/p7-400.jpg' }, plan_date: '2026-10-01' }
|
||||
const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date: '2026-10-02' }
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker')
|
||||
})
|
||||
|
||||
type Deferred = { resolve: (response: Response) => void; path: string; body: unknown }
|
||||
|
||||
// stubApi answers Home's reads like the server: the pending Rotation checks
|
||||
// are reviews, and each POST /api/v1/dinners/{id}/rotation-review waits in
|
||||
// decisions until the test answers it; a 200 answer takes the check off the
|
||||
// server's list, as the server does.
|
||||
function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) {
|
||||
const server = [...reviews]
|
||||
const decisions: Deferred[] = []
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const path = String(input)
|
||||
if (path === ME_PATH) return Promise.resolve(jsonResponse(200, ME))
|
||||
if (path === HOME_PATH) return Promise.resolve(jsonResponse(200, HOME))
|
||||
if (path === ROTATION_REVIEWS_PATH) return Promise.resolve(jsonResponse(200, { reviews: server }))
|
||||
const review = server.find((r) => rotationReviewPath(r.dinner.id) === path)
|
||||
if (init?.method === 'POST' && review !== undefined) {
|
||||
expect(new Headers(init.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
|
||||
const body: unknown = JSON.parse(String(init.body))
|
||||
return new Promise<Response>((resolve) =>
|
||||
decisions.push({
|
||||
path,
|
||||
body,
|
||||
resolve: (response) => {
|
||||
if (response.ok) server.splice(server.indexOf(review), 1)
|
||||
resolve(response)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, decisions, server }
|
||||
}
|
||||
|
||||
// decided is the server's answer to a decision on review.
|
||||
function decided(review: typeof SHAKSHUKA | typeof RAMEN, decision: string, dinnerStatus: string) {
|
||||
return jsonResponse(200, {
|
||||
dinner: review.dinner,
|
||||
plan_date: review.plan_date,
|
||||
decision,
|
||||
dinner_status: dinnerStatus,
|
||||
decided_at: '2026-10-02T13:00:00Z',
|
||||
updated_by: 'sub-1',
|
||||
})
|
||||
}
|
||||
|
||||
// renderHome loads /me (for the CSRF token an answer needs) and renders Home
|
||||
// at path.
|
||||
async function renderHome(path = '/') {
|
||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const router = createMemoryRouter([{ path: '/', element: <HomePage /> }], { initialEntries: [path] })
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const card = () => screen.findByRole('region', { name: 'Keep it in rotation?' })
|
||||
const posts = (fetchMock: ReturnType<typeof stubApi>['fetchMock']) =>
|
||||
fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
|
||||
describe('RotationCheck', () => {
|
||||
it('shows no card when nothing waits for an answer', async () => {
|
||||
const { fetchMock } = stubApi([])
|
||||
await renderHome()
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(ROTATION_REVIEWS_PATH, expect.anything()))
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks about the oldest pending check above the tiles, and decides nothing by itself', async () => {
|
||||
const { fetchMock } = stubApi([SHAKSHUKA, RAMEN])
|
||||
await renderHome()
|
||||
|
||||
const check = await card()
|
||||
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(within(check).getByRole('heading', { level: 2, name: 'Keep it in rotation?' })).toBeInTheDocument()
|
||||
expect(within(check).getByRole('img', { name: 'Shakshuka' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
|
||||
expect(screen.queryByText('Last night: Miso ramen')).not.toBeInTheDocument()
|
||||
|
||||
const keep = within(check).getByRole('button', { name: 'Keep it' })
|
||||
const drop = within(check).getByRole('button', { name: 'Not for us' })
|
||||
const notMade = within(check).getByRole('button', { name: "We didn't make it" })
|
||||
expect(keep.className).toMatch(/primary/)
|
||||
expect(drop.className).toMatch(/outline/)
|
||||
expect(notMade.className).toMatch(/notMade/)
|
||||
for (const button of [keep, drop, notMade]) expectMinHitArea(button)
|
||||
|
||||
const tiles = screen.getByRole('link', { name: 'Quick dinner' })
|
||||
expect(check.compareDocumentPosition(tiles) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(screen.getByRole('region', { name: 'Tonight' }).compareDocumentPosition(check) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
|
||||
// The card waits for the household: nothing is sent unasked.
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
|
||||
expect(posts(fetchMock)).toHaveLength(0)
|
||||
expect(await card()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Keep it', 'keep', 'regular', 'Added to your Regulars'],
|
||||
['Not for us', 'drop', 'archived', "Got it — we won't suggest it again"],
|
||||
["We didn't make it", 'not_made', 'quest_log', 'Back in your Quest log'],
|
||||
])('"%s" sends %s at once and confirms "%s"', async (label, decision, status, confirmation) => {
|
||||
const { fetchMock, decisions } = stubApi([SHAKSHUKA])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: label }))
|
||||
|
||||
// Applied before the server answers.
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(confirmation)
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
expect(decisions[0]).toMatchObject({ path: '/api/v1/dinners/d7/rotation-review', body: { decision } })
|
||||
expect(posts(fetchMock)).toHaveLength(1)
|
||||
|
||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, decision, status)))
|
||||
// Rotation checks are read again once saved; none is left.
|
||||
await waitFor(() =>
|
||||
expect(fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length).toBeGreaterThanOrEqual(2),
|
||||
)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(confirmation)
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reveals the next pending check at once, its answers locked until the first is saved', async () => {
|
||||
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
||||
|
||||
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
|
||||
const next = await card()
|
||||
expect(within(next).getByRole('img', { name: 'Miso ramen' })).toHaveTextContent('dish photo')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Added to your Regulars')
|
||||
for (const button of within(next).getAllByRole('button')) expect(button).toBeDisabled()
|
||||
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'keep', 'regular')))
|
||||
await waitFor(() => expect(within(next).getByRole('button', { name: 'Not for us' })).toBeEnabled())
|
||||
|
||||
fireEvent.click(within(next).getByRole('button', { name: 'Not for us' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Got it — we won't suggest it again")
|
||||
await waitFor(() => expect(decisions).toHaveLength(2))
|
||||
expect(decisions[1]).toMatchObject({ path: '/api/v1/dinners/d8/rotation-review', body: { decision: 'drop' } })
|
||||
act(() => decisions[1]!.resolve(decided(RAMEN, 'drop', 'archived')))
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
|
||||
expect(screen.queryByText(/Last night:/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('puts the check back with an ErrorBanner when the answer fails', async () => {
|
||||
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
||||
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
|
||||
act(() => decisions[0]!.resolve(errorResponse(500, 'internal')))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't save that answer. internal message")
|
||||
const check = await card()
|
||||
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(within(check).getByRole('button', { name: 'Keep it' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('reports an answer that is not that check decided that way, and shows what the server has', async () => {
|
||||
const { decisions } = stubApi([SHAKSHUKA])
|
||||
await renderHome()
|
||||
|
||||
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
|
||||
await waitFor(() => expect(decisions).toHaveLength(1))
|
||||
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'drop', 'archived')))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('response is not the Rotation check decided keep')
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
// The server closed the check, so the list read again has none.
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('reports Rotation checks it cannot load, with Retry', async () => {
|
||||
const answers = [errorResponse(500, 'internal'), jsonResponse(200, { reviews: [SHAKSHUKA] })]
|
||||
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(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens Home with the card from a rotation_review push notification', async () => {
|
||||
stubApi([SHAKSHUKA])
|
||||
const origin = 'https://mealquest.test'
|
||||
const { notice } = parsePushPayload(
|
||||
JSON.stringify({ event: 'rotation_review', title: 'Keep it in rotation?', body: 'How was Shakshuka?', url: '/' }),
|
||||
origin,
|
||||
)
|
||||
const path = clickPath({ url: notice.url }, origin)
|
||||
expect(path).toBe('/')
|
||||
|
||||
await renderHome(path)
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => {
|
||||
// navigator.serviceWorker is where the worker's postMessage to this
|
||||
// window arrives.
|
||||
const container = new EventTarget()
|
||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
||||
const { fetchMock, server } = stubApi([])
|
||||
const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length
|
||||
await renderHome('/')
|
||||
const home = await screen.findByRole('heading', { name: 'Pizza night' })
|
||||
await waitFor(() => expect(reads()).toBe(1))
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
|
||||
// Within the cached list's staleTime the Rotation check comes due and
|
||||
// its notification is tapped while Home is open at /.
|
||||
server.push(SHAKSHUKA)
|
||||
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(win.navigate).not.toHaveBeenCalled()
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(reads()).toBe(2)
|
||||
expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { useDecideRotationReview, useRotationReviews, type Decision, type RotationReview } from './rotation.ts'
|
||||
import styles from './RotationCheck.module.css'
|
||||
|
||||
// CONFIRMATION is what Home says once the household has answered.
|
||||
const CONFIRMATION: Record<Decision, string> = {
|
||||
keep: 'Added to your Regulars',
|
||||
drop: "Got it — we won't suggest it again",
|
||||
not_made: 'Back in your Quest log',
|
||||
}
|
||||
|
||||
type RotationCardProps = { review: RotationReview; disabled: boolean; onDecide: (decision: Decision) => void }
|
||||
|
||||
// RotationCard asks "Keep it in rotation?" about a tried dinner: its washed
|
||||
// photo, "Last night: {dinner}", and the three answers.
|
||||
function RotationCard({ review, disabled, onDecide }: RotationCardProps) {
|
||||
const { dinner } = review
|
||||
return (
|
||||
<section aria-label="Keep it in rotation?" className={styles.card}>
|
||||
<Photo id={dinner.photo_id} name={dinner.name} inset="10px" sizes="400px" className={styles.photo} />
|
||||
<div className={styles.body}>
|
||||
<p className={styles.kicker}>Last night: {dinner.name}</p>
|
||||
<h2 className={styles.question}>Keep it in rotation?</h2>
|
||||
<div className={styles.actions}>
|
||||
<Button disabled={disabled} onClick={() => onDecide('keep')}>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button variant="outline" disabled={disabled} onClick={() => onDecide('drop')}>
|
||||
Not for us
|
||||
</Button>
|
||||
</div>
|
||||
<button type="button" className={styles.notMade} disabled={disabled} onClick={() => onDecide('not_made')}>
|
||||
We didn't make it
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// RotationCheck is the Rotation check on Home: the card for the oldest
|
||||
// pending check (GET /api/v1/rotation-reviews), which stays until the
|
||||
// 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.
|
||||
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
|
||||
return (
|
||||
<>
|
||||
{answered !== undefined && (
|
||||
<p role="status" className={styles.confirmation}>
|
||||
{CONFIRMATION[answered.decision]}
|
||||
</p>
|
||||
)}
|
||||
{decide.isError && <ErrorBanner>Couldn't save that answer. {decide.error.message}</ErrorBanner>}
|
||||
{next !== undefined && (
|
||||
<RotationCard
|
||||
key={next.dinner.id}
|
||||
review={next}
|
||||
disabled={decide.isPending}
|
||||
onDecide={(decision) => decide.mutate({ review: next, decision })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOME } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { HOME_KEY, HOME_PATH, UPDATES_PATH, UPDATES_SEEN_PATH } from './api.ts'
|
||||
import { ROTATION_REVIEWS_PATH } from './rotation.ts'
|
||||
import { HomePage } from './HomePage.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -52,6 +53,7 @@ function stubServer(responders: Record<string, Responder> = {}) {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
[`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }),
|
||||
[`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES),
|
||||
[`GET ${ROTATION_REVIEWS_PATH}`]: () => jsonResponse(200, { reviews: [] }),
|
||||
[`POST ${UPDATES_SEEN_PATH}`]: () => {
|
||||
seen = true
|
||||
return new Response(null, { status: 204 })
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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 { photoId } from '../dinners/api.ts'
|
||||
import { isNotificationOpened } from '../push/notification.ts'
|
||||
|
||||
export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews'
|
||||
export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const
|
||||
const DINNERS_KEY = ['dinners'] as const
|
||||
|
||||
// rotationReviewPath is where the household answers "Keep it in rotation?"
|
||||
// for a tried dinner.
|
||||
export function rotationReviewPath(dinnerId: string): string {
|
||||
return `/api/v1/dinners/${encodeURIComponent(dinnerId)}/rotation-review`
|
||||
}
|
||||
|
||||
// Rotation check answers: keep makes the tried dinner a Regular, drop ("Not
|
||||
// for us") archives it so it is never suggested again, and not_made ("We
|
||||
// didn't make it") returns it to the Quest log. Keep is the only way a tried
|
||||
// dinner becomes a Regular; nothing decides one automatically.
|
||||
export const DECISIONS = ['keep', 'drop', 'not_made'] as const
|
||||
export type Decision = (typeof DECISIONS)[number]
|
||||
|
||||
// RotationReview is a pending Rotation check: the dinner tried, photo_id its
|
||||
// stored photo's id, and the household-local plan date, YYYY-MM-DD, it was
|
||||
// planned on.
|
||||
export type RotationReview = { dinner: { id: string; name: string; photo_id: string | undefined }; plan_date: string }
|
||||
|
||||
function parseReview(where: string, body: unknown): RotationReview {
|
||||
if (!isRecord(body) || typeof body.plan_date !== 'string') throw new Error(`${where}: a review is not a Rotation check`)
|
||||
const dinner = body.dinner
|
||||
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
|
||||
throw new Error(`${where}: a Rotation check has no dinner`)
|
||||
}
|
||||
return { dinner: { id: dinner.id, name: dinner.name, photo_id: photoId(where, dinner.photo) }, plan_date: body.plan_date }
|
||||
}
|
||||
|
||||
// parseRotationReviews checks a GET /api/v1/rotation-reviews body: the
|
||||
// pending Rotation checks, oldest plan date first.
|
||||
export function parseRotationReviews(where: string, body: unknown): RotationReview[] {
|
||||
if (!isRecord(body) || !Array.isArray(body.reviews)) throw new Error(`${where}: response is not the Rotation checks`)
|
||||
return body.reviews.map((review) => parseReview(where, review))
|
||||
}
|
||||
|
||||
// useRotationReviews is the pending Rotation checks (GET
|
||||
// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home
|
||||
// reads them afresh, and so does a notification tap that focuses Home
|
||||
// already open (the service worker's NotificationOpened), so Home opened
|
||||
// from a rotation_review notification shows the check it is about.
|
||||
export function useRotationReviews() {
|
||||
const { apiFetch } = useApi()
|
||||
const query = useQuery({
|
||||
queryKey: ROTATION_REVIEWS_KEY,
|
||||
queryFn: async () =>
|
||||
parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch<unknown>(ROTATION_REVIEWS_PATH)),
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
const { refetch } = query
|
||||
useEffect(() => {
|
||||
// Without a service worker there are no notifications to tap.
|
||||
if (!('serviceWorker' in navigator)) return
|
||||
const container = navigator.serviceWorker
|
||||
const onMessage = (event: MessageEvent<unknown>) => {
|
||||
if (isNotificationOpened(event.data)) void refetch()
|
||||
}
|
||||
container.addEventListener('message', onMessage)
|
||||
return () => container.removeEventListener('message', onMessage)
|
||||
}, [refetch])
|
||||
return query
|
||||
}
|
||||
|
||||
// useDecideRotationReview answers "Keep it in rotation?" for a pending
|
||||
// Rotation check (POST /api/v1/dinners/{id}/rotation-review). The answer
|
||||
// must be that dinner, decided that way.
|
||||
//
|
||||
// Home leaves the check out while its answer is pending (the mutation's
|
||||
// variables say which), so the next one shows at once; a read in flight is
|
||||
// stopped so it cannot bring the check back meanwhile. Once saved, the check
|
||||
// leaves the cached list; once failed, it is shown again as the list has it.
|
||||
// Either way the Rotation checks and the dinners lists (the Regulars and the
|
||||
// Quest log the answer moved it to) are read again.
|
||||
export function useDecideRotationReview() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<void, Error, { review: RotationReview; decision: Decision }>({
|
||||
mutationFn: async ({ review, decision }) => {
|
||||
const path = rotationReviewPath(review.dinner.id)
|
||||
const body = await apiFetch<unknown>(path, { method: 'POST', body: { decision } })
|
||||
if (!isRecord(body) || !isRecord(body.dinner) || body.dinner.id !== review.dinner.id || body.decision !== decision) {
|
||||
throw new Error(`POST ${path}: response is not the Rotation check decided ${decision}`)
|
||||
}
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ROTATION_REVIEWS_KEY })
|
||||
},
|
||||
onSuccess: (_data, { review }) => {
|
||||
queryClient.setQueryData<RotationReview[]>(ROTATION_REVIEWS_KEY, (reviews) =>
|
||||
reviews?.filter((r) => r.dinner.id !== review.dinner.id),
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ROTATION_REVIEWS_KEY })
|
||||
void queryClient.invalidateQueries({ queryKey: DINNERS_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GENERIC_NOTICE,
|
||||
appPath,
|
||||
clickPath,
|
||||
isNotificationOpened,
|
||||
openNotification,
|
||||
parsePushPayload,
|
||||
planClick,
|
||||
@@ -178,6 +179,7 @@ describe('openNotification', () => {
|
||||
focused,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
postMessage: vi.fn(),
|
||||
} satisfies ClickWindow
|
||||
}
|
||||
|
||||
@@ -193,6 +195,18 @@ describe('openNotification', () => {
|
||||
expect(win.focus).toHaveBeenCalled()
|
||||
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
||||
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
|
||||
expect(win.postMessage).not.toHaveBeenCalled()
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('focuses a window already at the url and tells it NotificationOpened instead of navigating', async () => {
|
||||
const win = window('/', true)
|
||||
const all = clients([win])
|
||||
await openNotification({ url: '/' }, all, origin)
|
||||
expect(win.focus).toHaveBeenCalled()
|
||||
expect(win.navigate).not.toHaveBeenCalled()
|
||||
expect(win.postMessage).toHaveBeenCalledWith({ type: 'NOTIFICATION_OPENED', url: `${origin}/` })
|
||||
expect(isNotificationOpened(win.postMessage.mock.calls[0]![0])).toBe(true)
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -150,6 +150,18 @@ export function planClick<W extends AppWindow>(windows: readonly W[], path: stri
|
||||
export type ClickWindow = AppWindow & {
|
||||
focus(): Promise<unknown>
|
||||
navigate(url: string): Promise<unknown>
|
||||
postMessage(message: NotificationOpened): void
|
||||
}
|
||||
|
||||
// NotificationOpened is the message a tap posts to an open window it focuses
|
||||
// without navigating, because the window is already at url: nothing remounts
|
||||
// there, so the screen reads afresh what the notification is about.
|
||||
export type NotificationOpened = { type: 'NOTIFICATION_OPENED'; url: string }
|
||||
|
||||
// isNotificationOpened reports whether a message from the service worker is
|
||||
// NotificationOpened.
|
||||
export function isNotificationOpened(message: unknown): message is NotificationOpened {
|
||||
return isRecord(message) && message.type === 'NOTIFICATION_OPENED' && typeof message.url === 'string'
|
||||
}
|
||||
|
||||
export type ClickClients<W extends ClickWindow> = {
|
||||
@@ -158,7 +170,8 @@ export type ClickClients<W extends ClickWindow> = {
|
||||
}
|
||||
|
||||
// openNotification carries out a tap on a notification with data: focus
|
||||
// first, while the tap still allows it, then navigate.
|
||||
// first, while the tap still allows it, then navigate; a window already at
|
||||
// the url is told NotificationOpened instead.
|
||||
export async function openNotification<W extends ClickWindow>(
|
||||
data: unknown,
|
||||
clients: ClickClients<W>,
|
||||
@@ -171,4 +184,5 @@ export async function openNotification<W extends ClickWindow>(
|
||||
}
|
||||
await plan.window.focus()
|
||||
if (plan.navigate) await plan.window.navigate(plan.url)
|
||||
else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user