Keep it in rotation card (Feature 21) #179

10 changed files with 598 additions and 8 deletions
+9 -3
View File
@@ -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()
+8 -3
View File
@@ -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")
+4 -1
View File
@@ -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">
+79
View File
@@ -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;
}
+281
View File
@@ -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)
})
})
+79
View File
@@ -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 })}
/>
)}
</>
)
}
+2
View File
@@ -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 })
+107
View File
@@ -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 })
},
})
}
+14
View File
@@ -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()
})
+15 -1
View File
@@ -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 })
}