Quick dinners screen (1j) (Feature 18) (#152)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #152.
This commit is contained in:
+19
-18
@@ -35,6 +35,7 @@ const SCREENS = {
|
|||||||
'/plan/shopping': true,
|
'/plan/shopping': true,
|
||||||
'/plan/shopping/archive': true,
|
'/plan/shopping/archive': true,
|
||||||
'/discover': true,
|
'/discover': true,
|
||||||
|
'/quick': true,
|
||||||
'/cook-from-pantry': true,
|
'/cook-from-pantry': true,
|
||||||
'/settings': true,
|
'/settings': true,
|
||||||
'/settings/ai': true,
|
'/settings/ai': true,
|
||||||
@@ -206,19 +207,19 @@ describe('App first-run redirect', () => {
|
|||||||
it('opens the routes once onboarding is finished', async () => {
|
it('opens the routes once onboarding is finished', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetchByPath(SIGNED_IN)
|
stubFetchByPath(SIGNED_IN)
|
||||||
renderAt('/quick')
|
renderAt('/settings/notifications')
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
|
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||||
renderAt('/quick')
|
renderAt('/settings/notifications')
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||||
screen.getByRole('button', { name: 'Retry' }).click()
|
screen.getByRole('button', { name: 'Retry' }).click()
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -226,12 +227,12 @@ describe('App bootstrap', () => {
|
|||||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||||
renderAt('/quick')
|
renderAt('/settings/notifications')
|
||||||
|
|
||||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
||||||
})
|
})
|
||||||
@@ -299,7 +300,7 @@ describe('App offline bootstrap', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['/quick', 'Quick dinners'],
|
['/settings/notifications', 'Notifications'],
|
||||||
['/dinner/01J9ZK', 'Dinner'],
|
['/dinner/01J9ZK', 'Dinner'],
|
||||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||||
await visitOnline()
|
await visitOnline()
|
||||||
@@ -323,7 +324,7 @@ describe('App offline bootstrap', () => {
|
|||||||
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
||||||
).mockRejectedValue(new TypeError('Failed to fetch')),
|
).mockRejectedValue(new TypeError('Failed to fetch')),
|
||||||
)
|
)
|
||||||
renderAt('/quick')
|
renderAt('/settings/notifications')
|
||||||
await vi.waitFor(() => expect(failMe).toBeDefined())
|
await vi.waitFor(() => expect(failMe).toBeDefined())
|
||||||
onLine = false
|
onLine = false
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -331,7 +332,7 @@ describe('App offline bootstrap', () => {
|
|||||||
})
|
})
|
||||||
act(() => failMe())
|
act(() => failMe())
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
@@ -340,15 +341,15 @@ describe('App offline bootstrap', () => {
|
|||||||
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
||||||
await visitOnline()
|
await visitOnline()
|
||||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||||
renderAt('/quick')
|
renderAt('/settings/notifications')
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports the failure offline when this device never verified a session', async () => {
|
it('reports the failure offline when this device never verified a session', async () => {
|
||||||
goOffline()
|
goOffline()
|
||||||
renderAt('/quick')
|
renderAt('/settings/notifications')
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||||
@@ -357,12 +358,12 @@ describe('App offline bootstrap', () => {
|
|||||||
it('reports the failure offline after the session was refused', async () => {
|
it('reports the failure offline after the session was refused', async () => {
|
||||||
await visitOnline()
|
await visitOnline()
|
||||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||||
const { location, unmount } = renderAt('/quick')
|
const { location, unmount } = renderAt('/settings/notifications')
|
||||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
|
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
|
||||||
unmount()
|
unmount()
|
||||||
|
|
||||||
goOffline()
|
goOffline()
|
||||||
renderAt('/quick')
|
renderAt('/settings/notifications')
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -370,13 +371,13 @@ describe('App offline bootstrap', () => {
|
|||||||
it('loads /me again on reconnect and refuses writes until it has', async () => {
|
it('loads /me again on reconnect and refuses writes until it has', async () => {
|
||||||
await visitOnline()
|
await visitOnline()
|
||||||
goOffline()
|
goOffline()
|
||||||
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
const api = createApiClient(fakeLocation('/settings/notifications'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
render(
|
render(
|
||||||
<InDataRouter path="/quick">
|
<InDataRouter path="/settings/notifications">
|
||||||
<App api={api} serviceWorker={undefined} />
|
<App api={api} serviceWorker={undefined} />
|
||||||
</InDataRouter>,
|
</InDataRouter>,
|
||||||
)
|
)
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||||
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||||
|
|
||||||
const fetchMock = stubFetchByPath(SIGNED_IN)
|
const fetchMock = stubFetchByPath(SIGNED_IN)
|
||||||
@@ -391,7 +392,7 @@ describe('App offline bootstrap', () => {
|
|||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||||
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('/settings/calendar renders the Google Calendar screen inside the shell', async () => {
|
it('/settings/calendar renders the Google Calendar screen inside the shell', async () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
|||||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||||
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
||||||
|
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
|
||||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||||
@@ -46,6 +47,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
|||||||
'/plan/shopping': <SupplyRunPage />,
|
'/plan/shopping': <SupplyRunPage />,
|
||||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||||
'/discover': <DiscoverPage />,
|
'/discover': <DiscoverPage />,
|
||||||
|
'/quick': <QuickDinnersPage />,
|
||||||
'/cook-from-pantry': <CookFromPantryPage />,
|
'/cook-from-pantry': <CookFromPantryPage />,
|
||||||
'/settings': <SettingsPage />,
|
'/settings': <SettingsPage />,
|
||||||
'/settings/ai': <AISettingsPage />,
|
'/settings/ai': <AISettingsPage />,
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/* Quick dinners, handoff mock 1j. */
|
||||||
|
.page {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 560px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The 36 px grape circle holding the zap. */
|
||||||
|
.zap {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zapIcon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-neutral-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo {
|
||||||
|
flex: none;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.failure {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The teal "+": a 36 px circle in the IconButton's 44 px hit box (scoped
|
||||||
|
to the rows to outrank the IconButton's 40 px face). */
|
||||||
|
.rows .add > span {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An idea added: a check in the "+"'s place, of the same size. */
|
||||||
|
.added {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 4px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-accent-2-100);
|
||||||
|
color: var(--color-accent-2-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "↻ Show me more": a grape-700 text button, 44 px tall. */
|
||||||
|
.more {
|
||||||
|
align-self: center;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more:focus-visible {
|
||||||
|
outline: 3px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonRow {
|
||||||
|
height: 84px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
|
import { dinnerBody } from '../test/dinners.ts'
|
||||||
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { QuickDinnersPage } from './QuickDinnersPage.tsx'
|
||||||
|
import styles from './QuickDinnersPage.module.css'
|
||||||
|
|
||||||
|
const QUICK = 'POST /api/v1/ideas/quick'
|
||||||
|
|
||||||
|
// idea is a grounded Quick dinner idea as the job's result carries it.
|
||||||
|
function idea(name: string, method: string, minutes: number | null, difficulty: string) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
method,
|
||||||
|
minutes_total: minutes,
|
||||||
|
difficulty,
|
||||||
|
quick: true,
|
||||||
|
recipe_id: `r-${name}`,
|
||||||
|
photo_url: `/media/photos/p-${name.length}-400.jpg`,
|
||||||
|
source_url: 'https://recipes.example/x',
|
||||||
|
site_name: 'Recipes',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ideas of mock 1j, and more for later pages and the Crock pot tab.
|
||||||
|
const ORZO = idea('Lemon garlic shrimp orzo', 'stovetop', 25, 'medium')
|
||||||
|
const QUESADILLAS = idea('Chicken quesadillas', 'stovetop', 20, 'easy')
|
||||||
|
const BROCCOLI = idea('Beef & broccoli', 'stovetop', 30, 'easy')
|
||||||
|
const GNOCCHI = idea('Pesto gnocchi & peas', 'stovetop', 15, 'easy')
|
||||||
|
const CHILI = idea('Crock pot chili', 'crockpot', 360, 'easy')
|
||||||
|
const CARNITAS = idea('Pork carnitas', 'crockpot', null, 'medium')
|
||||||
|
|
||||||
|
type JobBody = { status: string; result: unknown; error: string | null }
|
||||||
|
|
||||||
|
// state is a quick_ideas job's state at stage with ideas.
|
||||||
|
function state(status: string, stage: string, kind: string, ideas: unknown[], error: string | null = null): JobBody {
|
||||||
|
return { status, result: { stage, kind, exclude: [], ideas, dropped: 0 }, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||||
|
|
||||||
|
// stubServer answers "METHOD path" requests from responders, with /me; any
|
||||||
|
// other request fails the test. jobs holds each job's state, which every
|
||||||
|
// poll of it answers, so a test moves a job on by changing it.
|
||||||
|
function stubServer(responders: Record<string, Responder>) {
|
||||||
|
const jobs: Record<string, JobBody> = {}
|
||||||
|
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||||
|
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||||
|
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||||
|
const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key)
|
||||||
|
if (job !== null && !Object.hasOwn(responders, key)) {
|
||||||
|
const id = job[1]!
|
||||||
|
const body = jobs[id]
|
||||||
|
if (body === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||||
|
return Promise.resolve(jsonResponse(200, { id, kind: 'quick_ideas', ...body }))
|
||||||
|
}
|
||||||
|
const respond = Object.hasOwn(responders, key) ? responders[key] : undefined
|
||||||
|
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||||
|
return Promise.resolve(respond(init))
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
return { fetchMock, jobs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// quickServer answers each POST /ideas/quick with the next of jobIds.
|
||||||
|
function quickServer(jobIds: string[], responders: Record<string, Responder> = {}) {
|
||||||
|
let n = 0
|
||||||
|
return stubServer({
|
||||||
|
[QUICK]: () => {
|
||||||
|
const jobId = jobIds[n++]
|
||||||
|
if (jobId === undefined) throw new Error('unexpected quick search')
|
||||||
|
return jsonResponse(202, { jobId })
|
||||||
|
},
|
||||||
|
...responders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// calls are the requests sent as "METHOD path", in order.
|
||||||
|
function calls(fetchMock: ReturnType<typeof stubServer>['fetchMock'], key: string) {
|
||||||
|
return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// searches are the POST /ideas/quick bodies sent, in order.
|
||||||
|
function searches(fetchMock: ReturnType<typeof stubServer>['fetchMock']) {
|
||||||
|
return calls(fetchMock, QUICK).map((call) => JSON.parse(String(call[1]?.body)) as unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage(queryClient: QueryClient = createQueryClient()) {
|
||||||
|
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
|
await api.loadMe()
|
||||||
|
return render(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/quick']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/quick" element={<QuickDinnersPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tab(name: string) {
|
||||||
|
return within(screen.getByRole('radiogroup', { name: 'Kind of Quick dinner' })).getByRole('radio', { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(name: string) {
|
||||||
|
return screen.getByRole('listitem', { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowNames() {
|
||||||
|
return within(screen.getByRole('list', { name: 'Quick dinners' }))
|
||||||
|
.queryAllByRole('heading', { level: 2 })
|
||||||
|
.map((heading) => heading.textContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addButton(name: string) {
|
||||||
|
return within(row(name)).getByRole('button', { name: `Add ${name} to the Quest log` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// showMore waits for Show me more, shown once a page is kept.
|
||||||
|
function showMore() {
|
||||||
|
return screen.findByRole('button', { name: 'Show me more' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOW = { timeout: 3000 }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('QuickDinnersPage', () => {
|
||||||
|
it('renders mock 1j: zap header, tabs with 30 min or less chosen, its helper, and its first page', async () => {
|
||||||
|
const { fetchMock, jobs } = quickServer(['j1'])
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS, BROCCOLI, GNOCCHI])
|
||||||
|
const { container } = await renderPage()
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||||
|
const zap = container.querySelector(`.${styles.zap}`)!
|
||||||
|
expect(declaredStyle(zap).width).toBe('36px')
|
||||||
|
expect(declaredStyle(zap).background).toBe('var(--color-accent)')
|
||||||
|
expect(zap.querySelector('svg')).toHaveAttribute('stroke-width', '2.75')
|
||||||
|
expect(tab('30 min or less')).toBeChecked()
|
||||||
|
expect(tab('Crock pot')).not.toBeChecked()
|
||||||
|
expect(screen.getByText('Prep to table in 30 minutes or less, picked from your tastes.')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name])
|
||||||
|
expect(searches(fetchMock)).toEqual([{ kind: 'under30', exclude: [] }])
|
||||||
|
|
||||||
|
const orzo = within(row(ORZO.name))
|
||||||
|
expect(orzo.getByText('25 min · Medium')).toHaveClass(styles.meta!)
|
||||||
|
expect(orzo.getByRole('img', { name: ORZO.name })).toHaveAttribute('src', '/media/photos/p-24-400.jpg')
|
||||||
|
expect(declaredStyle(row(ORZO.name)).background).toBe('var(--color-neutral-100)')
|
||||||
|
expect(declaredStyle(row(ORZO.name)).borderRadius).toBe('24px')
|
||||||
|
expectMinHitArea(addButton(ORZO.name))
|
||||||
|
expect(addButton(ORZO.name)).toBeEnabled()
|
||||||
|
expectMinHitArea(await showMore())
|
||||||
|
expect(declaredStyle(await showMore()).color).toBe('var(--color-accent-700)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fills the rows progressively as the server grounds ideas, adding only once the page is found', async () => {
|
||||||
|
const { jobs } = quickServer(['j1'])
|
||||||
|
jobs.j1 = state('running', 'suggesting', 'under30', [])
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6))
|
||||||
|
expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'true')
|
||||||
|
|
||||||
|
jobs.j1 = state('running', 'grounding', 'under30', [ORZO])
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name }, SLOW)
|
||||||
|
expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5)
|
||||||
|
expect(addButton(ORZO.name)).toBeDisabled()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||||
|
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||||
|
await screen.findByRole('listitem', { name: QUESADILLAS.name }, SLOW)
|
||||||
|
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
|
||||||
|
expect(addButton(ORZO.name)).toBeEnabled()
|
||||||
|
expect(await showMore()).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tabs', () => {
|
||||||
|
it('searches each tab when first shown, keeps what each found, and says what each finds', async () => {
|
||||||
|
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||||
|
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS])
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
|
||||||
|
fireEvent.click(tab('Crock pot'))
|
||||||
|
expect(tab('Crock pot')).toBeChecked()
|
||||||
|
expect(screen.getByText(/in the crock pot.*picked from your tastes\./)).toBeInTheDocument()
|
||||||
|
await screen.findByRole('listitem', { name: CHILI.name })
|
||||||
|
expect(rowNames()).toEqual([CHILI.name, CARNITAS.name])
|
||||||
|
expect(searches(fetchMock)).toEqual([
|
||||||
|
{ kind: 'under30', exclude: [] },
|
||||||
|
{ kind: 'crockpot', exclude: [] },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Each tab's ideas are kept: going back asks for nothing.
|
||||||
|
fireEvent.click(tab('30 min or less'))
|
||||||
|
expect(rowNames()).toEqual([ORZO.name])
|
||||||
|
fireEvent.click(tab('Crock pot'))
|
||||||
|
expect(rowNames()).toEqual([CHILI.name, CARNITAS.name])
|
||||||
|
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a crock-pot idea as "Crock pot · 6 hr · Easy", without a time when the recipe has none', async () => {
|
||||||
|
const { jobs } = quickServer(['j1', 'j2'])
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||||
|
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS])
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(tab('Crock pot'))
|
||||||
|
await screen.findByRole('listitem', { name: CHILI.name })
|
||||||
|
|
||||||
|
expect(within(row(CHILI.name)).getByText('Crock pot · 6 hr · Easy')).toBeInTheDocument()
|
||||||
|
expect(within(row(CARNITAS.name)).getByText('Crock pot · Medium')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a tab for the session, also when the screen is left and opened again', async () => {
|
||||||
|
const { fetchMock, jobs } = quickServer(['j1'])
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||||
|
const queryClient = createQueryClient()
|
||||||
|
const { unmount } = await renderPage(queryClient)
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
await renderPage(queryClient)
|
||||||
|
expect(rowNames()).toEqual([ORZO.name])
|
||||||
|
expect(calls(fetchMock, QUICK)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a result that is not of the tab asked for', async () => {
|
||||||
|
const { jobs } = quickServer(['j1'])
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||||
|
await renderPage()
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
"Couldn't read the Quick dinners. GET /api/v1/jobs/j1: result is not Quick dinner ideas of kind under30",
|
||||||
|
)
|
||||||
|
expect(screen.queryByRole('listitem', { name: CHILI.name })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Add to Quest log', () => {
|
||||||
|
it('adds the idea with its "+", which becomes a check, kept across tabs', async () => {
|
||||||
|
const added = Promise.withResolvers<Response>()
|
||||||
|
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise })
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||||
|
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||||
|
|
||||||
|
fireEvent.click(addButton(QUESADILLAS.name))
|
||||||
|
await waitFor(() => expect(addButton(QUESADILLAS.name)).toBeDisabled())
|
||||||
|
added.resolve(jsonResponse(201, dinnerBody('dn-1', QUESADILLAS.name, { status: 'quest_log', origin: 'quick' })))
|
||||||
|
const quesadillas = within(row(QUESADILLAS.name))
|
||||||
|
expect(await quesadillas.findByRole('status')).toContainElement(quesadillas.getByRole('img', { name: 'In the Quest log' }))
|
||||||
|
expect(quesadillas.queryByRole('button')).toBeNull()
|
||||||
|
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1)
|
||||||
|
// The other row is untouched.
|
||||||
|
expect(addButton(ORZO.name)).toBeEnabled()
|
||||||
|
|
||||||
|
fireEvent.click(tab('Crock pot'))
|
||||||
|
await screen.findByRole('listitem', { name: CHILI.name })
|
||||||
|
fireEvent.click(tab('30 min or less'))
|
||||||
|
expect(within(row(QUESADILLAS.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks an add that settles while another tab is shown', async () => {
|
||||||
|
const added = Promise.withResolvers<Response>()
|
||||||
|
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/0/quest-log': () => added.promise })
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||||
|
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||||
|
const queryClient = createQueryClient()
|
||||||
|
await renderPage(queryClient)
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
|
||||||
|
fireEvent.click(addButton(ORZO.name))
|
||||||
|
await waitFor(() => expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1))
|
||||||
|
fireEvent.click(tab('Crock pot'))
|
||||||
|
await screen.findByRole('listitem', { name: CHILI.name })
|
||||||
|
added.resolve(jsonResponse(201, dinnerBody('dn-1', ORZO.name, { status: 'quest_log', origin: 'quick' })))
|
||||||
|
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||||
|
|
||||||
|
fireEvent.click(tab('30 min or less'))
|
||||||
|
expect(within(row(ORZO.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||||
|
expect(addButton(QUESADILLAS.name)).toBeEnabled()
|
||||||
|
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows its failure in its row and lets it be pressed again', async () => {
|
||||||
|
let fail = true
|
||||||
|
const { jobs } = quickServer(['j1'], {
|
||||||
|
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
|
||||||
|
fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', ORZO.name, { status: 'quest_log' })),
|
||||||
|
})
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
const orzo = within(row(ORZO.name))
|
||||||
|
|
||||||
|
fireEvent.click(addButton(ORZO.name))
|
||||||
|
expect(await orzo.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message")
|
||||||
|
|
||||||
|
fail = false
|
||||||
|
fireEvent.click(addButton(ORZO.name))
|
||||||
|
expect(await orzo.findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||||
|
expect(orzo.queryByRole('alert')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Show me more', () => {
|
||||||
|
it('asks for the same kind excluding every name shown, and adds from the page that found it', async () => {
|
||||||
|
const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'], {
|
||||||
|
'POST /api/v1/ideas/j2/items/0/quest-log': () => jsonResponse(201, dinnerBody('dn-1', BROCCOLI.name, { status: 'quest_log' })),
|
||||||
|
})
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||||
|
jobs.j2 = state('running', 'grounding', 'under30', [BROCCOLI])
|
||||||
|
jobs.j3 = state('succeeded', 'done', 'under30', [GNOCCHI])
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||||
|
|
||||||
|
fireEvent.click(await showMore())
|
||||||
|
await screen.findByRole('listitem', { name: BROCCOLI.name }, SLOW)
|
||||||
|
// The first page stays while the next fills.
|
||||||
|
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name])
|
||||||
|
expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5)
|
||||||
|
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||||
|
|
||||||
|
jobs.j2 = state('succeeded', 'done', 'under30', [BROCCOLI])
|
||||||
|
await screen.findByRole('button', { name: 'Show me more' }, SLOW)
|
||||||
|
fireEvent.click(addButton(BROCCOLI.name))
|
||||||
|
expect(await within(row(BROCCOLI.name)).findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(await showMore())
|
||||||
|
await screen.findByRole('listitem', { name: GNOCCHI.name })
|
||||||
|
expect(searches(fetchMock)).toEqual([
|
||||||
|
{ kind: 'under30', exclude: [] },
|
||||||
|
{ kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name] },
|
||||||
|
{ kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name, BROCCOLI.name] },
|
||||||
|
])
|
||||||
|
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says every idea has been shown when a later page finds none', async () => {
|
||||||
|
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||||
|
jobs.j2 = state('failed', 'grounding', 'under30', [], 'no_ideas_found')
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
|
||||||
|
fireEvent.click(await showMore())
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent("That's every Quick dinner for now.")
|
||||||
|
expect(screen.queryByRole('alert')).toBeNull()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||||
|
expect(rowNames()).toEqual([ORZO.name])
|
||||||
|
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a later page that fails with the ErrorBanner, keeping the rows, and Retry asks again', async () => {
|
||||||
|
const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'])
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||||
|
jobs.j2 = state('failed', 'grounding', 'under30', [], 'search_failed')
|
||||||
|
jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS])
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
|
||||||
|
fireEvent.click(await showMore())
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent("Couldn't find Quick dinners. The recipe search couldn't be reached.")
|
||||||
|
expect(rowNames()).toEqual([ORZO.name])
|
||||||
|
|
||||||
|
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||||
|
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||||
|
expect(searches(fetchMock)[2]).toEqual({ kind: 'under30', exclude: [ORZO.name] })
|
||||||
|
expect(screen.queryByRole('alert')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('errors', () => {
|
||||||
|
it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => {
|
||||||
|
let n = 0
|
||||||
|
const { fetchMock, jobs } = stubServer({
|
||||||
|
[QUICK]: () => (++n === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })),
|
||||||
|
})
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
|
||||||
|
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
expect(searches(fetchMock)).toEqual([
|
||||||
|
{ kind: 'under30', exclude: [] },
|
||||||
|
{ kind: 'under30', exclude: [] },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a first page that cannot start across a tab switch, asking again only on Retry', async () => {
|
||||||
|
let under30 = 0
|
||||||
|
const { fetchMock, jobs } = stubServer({
|
||||||
|
[QUICK]: (init) => {
|
||||||
|
const { kind } = JSON.parse(String(init?.body)) as { kind: string }
|
||||||
|
if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' })
|
||||||
|
return ++under30 === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||||
|
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('alert')
|
||||||
|
|
||||||
|
fireEvent.click(tab('Crock pot'))
|
||||||
|
await screen.findByRole('listitem', { name: CHILI.name })
|
||||||
|
fireEvent.click(tab('30 min or less'))
|
||||||
|
|
||||||
|
const alert = screen.getByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
|
||||||
|
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
|
||||||
|
expect(searches(fetchMock)).toEqual([
|
||||||
|
{ kind: 'under30', exclude: [] },
|
||||||
|
{ kind: 'crockpot', exclude: [] },
|
||||||
|
])
|
||||||
|
|
||||||
|
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
expect(screen.queryByRole('alert')).toBeNull()
|
||||||
|
expect(calls(fetchMock, QUICK)).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a later page that cannot start across a tab switch, asking again only on Retry', async () => {
|
||||||
|
let under30 = 0
|
||||||
|
const { fetchMock, jobs } = stubServer({
|
||||||
|
[QUICK]: (init) => {
|
||||||
|
const { kind } = JSON.parse(String(init?.body)) as { kind: string }
|
||||||
|
if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' })
|
||||||
|
under30++
|
||||||
|
return under30 === 2 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: under30 === 1 ? 'j1' : 'j3' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||||
|
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||||
|
jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS])
|
||||||
|
await renderPage()
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
fireEvent.click(await showMore())
|
||||||
|
await screen.findByRole('alert')
|
||||||
|
|
||||||
|
fireEvent.click(tab('Crock pot'))
|
||||||
|
await screen.findByRole('listitem', { name: CHILI.name })
|
||||||
|
fireEvent.click(tab('30 min or less'))
|
||||||
|
|
||||||
|
const alert = screen.getByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
|
||||||
|
expect(rowNames()).toEqual([ORZO.name])
|
||||||
|
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||||
|
expect(calls(fetchMock, QUICK)).toHaveLength(3)
|
||||||
|
|
||||||
|
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||||
|
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||||
|
expect(searches(fetchMock)[3]).toEqual({ kind: 'under30', exclude: [ORZO.name] })
|
||||||
|
expect(screen.queryByRole('alert')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['ai_failed', "The AI assistant couldn't come up with ideas."],
|
||||||
|
['no_ideas_found', 'No ideas came back with a recipe to match.'],
|
||||||
|
['search_not_configured', 'Recipe search is not set up.'],
|
||||||
|
['internal_error', 'Something went wrong on the server.'],
|
||||||
|
])('shows a first page failed with %s with the ErrorBanner and no rows, and Retry searches again', async (code, message) => {
|
||||||
|
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
|
||||||
|
jobs.j1 = state('failed', 'grounding', 'under30', [], code)
|
||||||
|
jobs.j2 = state('succeeded', 'done', 'under30', [ORZO])
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent(`Couldn't find Quick dinners. ${message}`)
|
||||||
|
expect(screen.queryAllByRole('listitem', { name: /./ })).toHaveLength(0)
|
||||||
|
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
|
||||||
|
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||||
|
|
||||||
|
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||||
|
expect(screen.queryByRole('alert')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
|
||||||
|
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found') })
|
||||||
|
jobs.j2 = state('succeeded', 'done', 'under30', [ORZO])
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent("Couldn't check on the Quick dinners. not_found message")
|
||||||
|
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||||
|
await screen.findByRole('listitem', { name: ORZO.name })
|
||||||
|
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||||
|
expect(screen.queryByRole('alert')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { Check, Plus, RefreshCw, Zap } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useIsMutating } from '@tanstack/react-query'
|
||||||
|
import { useJob } from '../api/index.ts'
|
||||||
|
import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx'
|
||||||
|
import { duration } from '../plan/PlanPage.tsx'
|
||||||
|
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||||
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
|
import { Photo } from '../ui/Photo.tsx'
|
||||||
|
import { Segmented } from '../ui/Segmented.tsx'
|
||||||
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
|
import {
|
||||||
|
ideasJobFailure,
|
||||||
|
ideasJobLost,
|
||||||
|
MAX_EXCLUDE,
|
||||||
|
parseQuickResult,
|
||||||
|
QUICK_IDEAS_KEPT,
|
||||||
|
QUICK_JOB_KIND,
|
||||||
|
quickTabKey,
|
||||||
|
shownNames,
|
||||||
|
useAddIdeaToQuestLog,
|
||||||
|
useFindQuickIdeas,
|
||||||
|
useQuickTab,
|
||||||
|
type IdeaRef,
|
||||||
|
type QuickIdea,
|
||||||
|
type QuickKind,
|
||||||
|
} from './api.ts'
|
||||||
|
import styles from './QuickDinnersPage.module.css'
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ value: 'crockpot', label: 'Crock pot' },
|
||||||
|
{ value: 'under30', label: '30 min or less' },
|
||||||
|
] as const satisfies readonly { value: QuickKind; label: string }[]
|
||||||
|
|
||||||
|
// helpers says what each tab finds.
|
||||||
|
const helpers: Record<QuickKind, string> = {
|
||||||
|
crockpot: 'Set it going in the crock pot and come back to dinner, picked from your tastes.',
|
||||||
|
under30: 'Prep to table in 30 minutes or less, picked from your tastes.',
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's code for a search that found nothing: on a later page, it
|
||||||
|
// means the tab has shown every idea there is.
|
||||||
|
const NO_IDEAS_FOUND = 'no_ideas_found'
|
||||||
|
|
||||||
|
// meta is a row's "25 min · Medium" line: "Crock pot" first for a crock-pot
|
||||||
|
// idea, and the time only when the recipe says it.
|
||||||
|
function meta(idea: QuickIdea): string {
|
||||||
|
return [
|
||||||
|
idea.method === 'crockpot' && methodLabels.crockpot,
|
||||||
|
idea.minutes_total !== null && duration(idea.minutes_total),
|
||||||
|
DIFFICULTY_LABEL[idea.difficulty],
|
||||||
|
]
|
||||||
|
.filter((part): part is string => typeof part === 'string')
|
||||||
|
.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuickRow is one idea (mock 1j): its washed photo, name and meta line, and
|
||||||
|
// the teal "+" that adds it to the Quest log, a check once it is there.
|
||||||
|
// ideaRef is null while its job runs: the server takes an idea only once
|
||||||
|
// the job has succeeded. onAdded marks the idea added, also if the row has
|
||||||
|
// unmounted before the add settles.
|
||||||
|
function QuickRow({
|
||||||
|
idea,
|
||||||
|
ideaRef,
|
||||||
|
added,
|
||||||
|
onAdded,
|
||||||
|
}: {
|
||||||
|
idea: QuickIdea
|
||||||
|
ideaRef: IdeaRef | null
|
||||||
|
added: boolean
|
||||||
|
onAdded: (idea: IdeaRef) => void
|
||||||
|
}) {
|
||||||
|
const questLog = useAddIdeaToQuestLog(onAdded)
|
||||||
|
return (
|
||||||
|
<li className={styles.row} aria-label={idea.name}>
|
||||||
|
<Photo id={idea.photo_id} name={idea.name} radius="50%" sizes="64px" className={styles.photo} />
|
||||||
|
<div className={styles.text}>
|
||||||
|
<h2 className={styles.name}>{idea.name}</h2>
|
||||||
|
<span className={styles.meta}>{meta(idea)}</span>
|
||||||
|
{questLog.isError && (
|
||||||
|
<p className={styles.failure} role="alert">
|
||||||
|
Couldn't add it to the Quest log. {questLog.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{added ? (
|
||||||
|
<span className={styles.added} role="status">
|
||||||
|
<Icon icon={Check} size="nav" label="In the Quest log" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
label={`Add ${idea.name} to the Quest log`}
|
||||||
|
tone="teal"
|
||||||
|
className={styles.add}
|
||||||
|
disabled={ideaRef === null || questLog.isPending}
|
||||||
|
onClick={() => questLog.mutate(ideaRef!)}
|
||||||
|
>
|
||||||
|
<Icon icon={Plus} size="nav" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skeletons stand in for the count ideas still to come.
|
||||||
|
function Skeletons({ count }: { count: number }) {
|
||||||
|
return Array.from({ length: count }, (_, i) => (
|
||||||
|
<li key={i} aria-hidden="true">
|
||||||
|
<Skeleton data-testid="skeleton-row" className={styles.skeletonRow} />
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuickTabPanel is one tab's ideas: its pages so far, the page being
|
||||||
|
// found, filling as the server grounds it, and Show me more, which asks for
|
||||||
|
// a page excluding every name shown. The tab's first page is asked for when
|
||||||
|
// the tab is first shown; what it finds is kept for the session.
|
||||||
|
function QuickTabPanel({ kind }: { kind: QuickKind }) {
|
||||||
|
const [tab, update] = useQuickTab(kind)
|
||||||
|
const { mutate } = useFindQuickIdeas(kind)
|
||||||
|
// A page being asked for, also by this tab before it was last shown.
|
||||||
|
const starting = useIsMutating({ mutationKey: quickTabKey(kind) }) > 0
|
||||||
|
const job = useJob<unknown>(QUICK_JOB_KIND, tab.jobId)
|
||||||
|
const names = shownNames(tab)
|
||||||
|
|
||||||
|
// ask asks for the next page, excluding every name shown; Retry asks
|
||||||
|
// again for the page that failed. A page that could not start stays the
|
||||||
|
// tab's startError until Retry: nothing asks again by itself.
|
||||||
|
const ask = () => mutate(names)
|
||||||
|
|
||||||
|
// The first page, asked for once.
|
||||||
|
const asked = useRef(false)
|
||||||
|
const fresh = tab.pages.length === 0 && tab.jobId === null && !tab.ended && tab.startError === null && !starting
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fresh || asked.current) return
|
||||||
|
asked.current = true
|
||||||
|
mutate([])
|
||||||
|
}, [fresh, mutate])
|
||||||
|
|
||||||
|
// The job's result as far as it has gone, or why it cannot be read.
|
||||||
|
const found = useMemo(() => {
|
||||||
|
if (job.result === null || tab.jobId === null) return { result: null, error: null }
|
||||||
|
try {
|
||||||
|
return { result: parseQuickResult(`GET /api/v1/jobs/${tab.jobId}`, kind, job.result), error: null }
|
||||||
|
} catch (error) {
|
||||||
|
return { result: null, error: error as Error }
|
||||||
|
}
|
||||||
|
}, [job.result, tab.jobId, kind])
|
||||||
|
|
||||||
|
const failure = job.status === 'failed' ? job.error!.message : null
|
||||||
|
// A later page that finds nothing means there is nothing more to show.
|
||||||
|
const exhausted = failure === NO_IDEAS_FOUND && tab.pages.length > 0
|
||||||
|
|
||||||
|
// A page found is kept with the tab's pages; a page that ends the tab
|
||||||
|
// ends it.
|
||||||
|
const jobId = tab.jobId
|
||||||
|
const ideas = job.status === 'succeeded' ? (found.result?.ideas ?? null) : null
|
||||||
|
useEffect(() => {
|
||||||
|
if (jobId === null) return
|
||||||
|
if (ideas !== null) update((t) => (t.jobId === jobId ? { ...t, pages: [...t.pages, { jobId, ideas }], jobId: null } : t))
|
||||||
|
else if (exhausted) update((t) => (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t))
|
||||||
|
}, [jobId, ideas, exhausted, update])
|
||||||
|
|
||||||
|
const markAdded = ({ jobId, index }: IdeaRef) => update((t) => ({ ...t, added: [...t.added, `${jobId}:${index}`] }))
|
||||||
|
|
||||||
|
// The rows of every page found, then of the page being found, in one
|
||||||
|
// keyed list: a page kept with the tab's pages keeps its rows, and with
|
||||||
|
// them any add still under way.
|
||||||
|
const rows: { idea: QuickIdea; ref: IdeaRef | null; key: string }[] = tab.pages.flatMap((page) =>
|
||||||
|
page.ideas.map((idea, index) => ({ idea, ref: { jobId: page.jobId, index }, key: `${page.jobId}:${index}` })),
|
||||||
|
)
|
||||||
|
|
||||||
|
let next = null
|
||||||
|
if (starting) {
|
||||||
|
next = <Skeletons count={QUICK_IDEAS_KEPT} />
|
||||||
|
} else if (tab.startError !== null) {
|
||||||
|
next = (
|
||||||
|
<li>
|
||||||
|
<ErrorBanner onRetry={ask}>Couldn't start looking for Quick dinners. {tab.startError.message}</ErrorBanner>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
} else if (jobId !== null && failure !== null && !exhausted) {
|
||||||
|
next = (
|
||||||
|
<li>
|
||||||
|
<ErrorBanner onRetry={ask}>Couldn't find Quick dinners. {ideasJobFailure(failure)}</ErrorBanner>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
} else if (found.error !== null) {
|
||||||
|
next = (
|
||||||
|
<li>
|
||||||
|
<ErrorBanner onRetry={ask}>Couldn't read the Quick dinners. {found.error.message}</ErrorBanner>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
} else if (jobId !== null && !exhausted) {
|
||||||
|
const shown = found.result?.ideas ?? []
|
||||||
|
const done = job.status === 'succeeded'
|
||||||
|
shown.forEach((idea, index) => rows.push({ idea, ref: done ? { jobId, index } : null, key: `${jobId}:${index}` }))
|
||||||
|
// A job not found will not be found by asking again: Retry asks for the
|
||||||
|
// page again. Any other failed poll is asked again.
|
||||||
|
const lost = ideasJobLost(job.error)
|
||||||
|
next = (
|
||||||
|
<>
|
||||||
|
{!done && <Skeletons count={Math.max(QUICK_IDEAS_KEPT - shown.length, 0)} />}
|
||||||
|
{job.error !== null && (
|
||||||
|
<li>
|
||||||
|
<ErrorBanner onRetry={lost ? ask : job.poll}>Couldn't check on the Quick dinners. {job.error.message}</ErrorBanner>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show me more once the tab is between pages; the server takes at most
|
||||||
|
// MAX_EXCLUDE names to leave out, so past that there is no more to ask.
|
||||||
|
const between = !starting && tab.startError === null && jobId === null && tab.pages.length > 0
|
||||||
|
const allShown = tab.ended || names.length > MAX_EXCLUDE
|
||||||
|
const busy = starting || (jobId !== null && failure === null && found.error === null && job.status !== 'succeeded')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label={TABS.find((t) => t.value === kind)!.label} className={styles.panel}>
|
||||||
|
<p className={styles.helper}>{helpers[kind]}</p>
|
||||||
|
<ul aria-busy={busy} aria-label="Quick dinners" className={styles.rows}>
|
||||||
|
{rows.map(({ idea, ref, key }) => (
|
||||||
|
<QuickRow key={key} idea={idea} ideaRef={ref} added={tab.added.includes(key)} onAdded={markAdded} />
|
||||||
|
))}
|
||||||
|
{next}
|
||||||
|
</ul>
|
||||||
|
{between &&
|
||||||
|
(allShown ? (
|
||||||
|
<p className={styles.end} role="status">
|
||||||
|
That's every Quick dinner for now.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button type="button" className={styles.more} onClick={ask}>
|
||||||
|
<Icon icon={RefreshCw} /> Show me more
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuickDinnersPage is mock 1j, Quick dinners: a Crock pot | 30 min or less
|
||||||
|
// tab for each kind of Quick dinner, each a quick_ideas search of its own
|
||||||
|
// whose ideas fill as the server grounds them, kept for the session.
|
||||||
|
export function QuickDinnersPage() {
|
||||||
|
const [kind, setKind] = useState<QuickKind>('under30')
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<span className={styles.zap}>
|
||||||
|
<Icon icon={Zap} className={styles.zapIcon} />
|
||||||
|
</span>
|
||||||
|
<h1 className={styles.title}>Quick dinners</h1>
|
||||||
|
</header>
|
||||||
|
<Segmented label="Kind of Quick dinner" options={TABS} value={kind} onValueChange={setKind} className={styles.tabs} />
|
||||||
|
<QuickTabPanel key={kind} kind={kind} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+147
-5
@@ -1,7 +1,8 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useCallback } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ApiError, useApi } from '../api/index.ts'
|
import { ApiError, useApi } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { parseDinner } from '../dinners/api.ts'
|
import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method } from '../dinners/api.ts'
|
||||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||||
import { parsePlan, planKey } from '../plan/api.ts'
|
import { parsePlan, planKey } from '../plan/api.ts'
|
||||||
|
|
||||||
@@ -141,6 +142,143 @@ export function useFindIdeas() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QUICK_PATH starts finding a page of Quick dinner ideas, and
|
||||||
|
// QUICK_JOB_KIND is the kind of the job that finds it.
|
||||||
|
export const QUICK_PATH = `${IDEAS_PATH}/quick`
|
||||||
|
export const QUICK_JOB_KIND = 'quick_ideas'
|
||||||
|
|
||||||
|
// QUICK_KINDS are the kinds of Quick dinner, the tabs of Quick dinners in
|
||||||
|
// order (skills.QuickKinds): cooked in a crock pot, or 30 minutes or less
|
||||||
|
// from prep to table.
|
||||||
|
export const QUICK_KINDS = ['crockpot', 'under30'] as const
|
||||||
|
export type QuickKind = (typeof QUICK_KINDS)[number]
|
||||||
|
|
||||||
|
// QUICK_IDEAS_KEPT is the most ideas one page of Quick dinners shows
|
||||||
|
// (skills.QuickIdeasKept), and MAX_EXCLUDE the most names a request may
|
||||||
|
// exclude (skills.MaxExclude).
|
||||||
|
export const QUICK_IDEAS_KEPT = 6
|
||||||
|
export const MAX_EXCLUDE = 60
|
||||||
|
|
||||||
|
// QuickIdea is a grounded Quick dinner idea as a row shows it. method,
|
||||||
|
// minutes_total and difficulty are the grounded recipe's; minutes_total is
|
||||||
|
// null when the recipe does not say, which only a crock-pot idea may be.
|
||||||
|
// photo_id is the stored photo's, from photo_url.
|
||||||
|
export type QuickIdea = {
|
||||||
|
name: string
|
||||||
|
method: Method
|
||||||
|
minutes_total: number | null
|
||||||
|
difficulty: Difficulty
|
||||||
|
photo_id: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuickResult is a quick_ideas job's result, published as it goes: the
|
||||||
|
// ideas grounded so far, in the order the server shows them.
|
||||||
|
export type QuickResult = {
|
||||||
|
stage: IdeaStage
|
||||||
|
kind: QuickKind
|
||||||
|
ideas: QuickIdea[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuickIdea(where: string, body: unknown): QuickIdea {
|
||||||
|
if (
|
||||||
|
!isRecord(body) ||
|
||||||
|
typeof body.name !== 'string' ||
|
||||||
|
!(METHODS as readonly unknown[]).includes(body.method) ||
|
||||||
|
(body.minutes_total !== null && !Number.isInteger(body.minutes_total)) ||
|
||||||
|
!(DIFFICULTIES as readonly unknown[]).includes(body.difficulty)
|
||||||
|
) {
|
||||||
|
throw new Error(`${where}: result has an idea that is not a grounded Quick dinner`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: body.name,
|
||||||
|
method: body.method as Method,
|
||||||
|
minutes_total: body.minutes_total as number | null,
|
||||||
|
difficulty: body.difficulty as Difficulty,
|
||||||
|
photo_id: photoId(where, body.photo_url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseQuickResult checks a quick_ideas job's result, which useJob passes
|
||||||
|
// on unread, is a page of the kind asked for.
|
||||||
|
export function parseQuickResult(where: string, kind: QuickKind, body: unknown): QuickResult {
|
||||||
|
if (
|
||||||
|
!isRecord(body) ||
|
||||||
|
typeof body.stage !== 'string' ||
|
||||||
|
!(IDEA_STAGES as readonly string[]).includes(body.stage) ||
|
||||||
|
body.kind !== kind ||
|
||||||
|
!isStrings(body.exclude) ||
|
||||||
|
!Array.isArray(body.ideas) ||
|
||||||
|
typeof body.dropped !== 'number'
|
||||||
|
) {
|
||||||
|
throw new Error(`${where}: result is not Quick dinner ideas of kind ${kind}`)
|
||||||
|
}
|
||||||
|
return { stage: body.stage as IdeaStage, kind, ideas: body.ideas.map((idea) => parseQuickIdea(where, idea)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuickPage is a page of Quick dinner ideas its job found.
|
||||||
|
export type QuickPage = { jobId: string; ideas: QuickIdea[] }
|
||||||
|
|
||||||
|
// QuickTab is what one tab of Quick dinners has found this session: the
|
||||||
|
// pages found, the job finding the next one, whether the server has said
|
||||||
|
// there are no more, why the last page asked for could not start, kept
|
||||||
|
// until Retry asks again, and the ideas added to the Quest log, as
|
||||||
|
// "jobId:index".
|
||||||
|
export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; startError: Error | null; added: string[] }
|
||||||
|
|
||||||
|
const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: [] }
|
||||||
|
|
||||||
|
export function quickTabKey(kind: QuickKind) {
|
||||||
|
return ['ideas', 'quick', kind] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
// shownNames are the names of every idea a tab shows, which its next page
|
||||||
|
// excludes so that nothing repeats.
|
||||||
|
export function shownNames(tab: QuickTab): string[] {
|
||||||
|
return tab.pages.flatMap((page) => page.ideas.map((idea) => idea.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// useQuickTab is a tab's QuickTab, held in the query cache for the session
|
||||||
|
// (never stale, never collected) so that leaving the tab or the screen
|
||||||
|
// keeps it, and update, which changes it. Nothing is fetched: a refetch
|
||||||
|
// reads back what the tab holds.
|
||||||
|
export function useQuickTab(kind: QuickKind) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: quickTabKey(kind),
|
||||||
|
queryFn: () => queryClient.getQueryData<QuickTab>(quickTabKey(kind)) ?? NEW_TAB,
|
||||||
|
initialData: NEW_TAB,
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: Infinity,
|
||||||
|
})
|
||||||
|
const update = useCallback(
|
||||||
|
(change: (tab: QuickTab) => QuickTab) => queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => change(tab ?? NEW_TAB)),
|
||||||
|
[queryClient, kind],
|
||||||
|
)
|
||||||
|
return [data, update] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
// useFindQuickIdeas starts finding a page of Quick dinner ideas of kind,
|
||||||
|
// none named in exclude (POST /api/v1/ideas/quick), and makes its job the
|
||||||
|
// tab's job, or its failure the tab's startError, also if the screen has
|
||||||
|
// been left meanwhile. Its mutationKey is the tab's, so a tab shown again
|
||||||
|
// knows a page is still being asked for.
|
||||||
|
export function useFindQuickIdeas(kind: QuickKind) {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationKey: quickTabKey(kind),
|
||||||
|
mutationFn: async (exclude: string[]): Promise<string> => {
|
||||||
|
queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError: null }))
|
||||||
|
const body = await apiFetch<unknown>(QUICK_PATH, { method: 'POST', body: { kind, exclude } })
|
||||||
|
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${QUICK_PATH}: response has no jobId`)
|
||||||
|
const jobId = body.jobId
|
||||||
|
queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), jobId }))
|
||||||
|
return jobId
|
||||||
|
},
|
||||||
|
onError: (startError: Error) => queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// IdeaRef is idea index of the succeeded ideas job jobId.
|
// IdeaRef is idea index of the succeeded ideas job jobId.
|
||||||
export type IdeaRef = { jobId: string; index: number }
|
export type IdeaRef = { jobId: string; index: number }
|
||||||
|
|
||||||
@@ -151,8 +289,9 @@ function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight
|
|||||||
// useAddIdeaToQuestLog adds an idea to the Quest log (POST
|
// useAddIdeaToQuestLog adds an idea to the Quest log (POST
|
||||||
// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the
|
// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the
|
||||||
// dinner's name. Asking again answers the same dinner. The Quest log is
|
// dinner's name. Asking again answers the same dinner. The Quest log is
|
||||||
// read again after it.
|
// read again after it, and onAdded told, also if the caller has unmounted
|
||||||
export function useAddIdeaToQuestLog() {
|
// meanwhile.
|
||||||
|
export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef) => void) {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -160,7 +299,10 @@ export function useAddIdeaToQuestLog() {
|
|||||||
const path = ideaPath(idea, 'quest-log')
|
const path = ideaPath(idea, 'quest-log')
|
||||||
return parseDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' })).name
|
return parseDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' })).name
|
||||||
},
|
},
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
onSuccess: (_name, idea) => {
|
||||||
|
onAdded?.(idea)
|
||||||
|
return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user