Feature 12: Regular dinners and onboarding (#94)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #94.
This commit is contained in:
+83
-12
@@ -7,6 +7,7 @@ import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stub
|
||||
import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
import { dinnerBody } from './test/dinners.ts'
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
@@ -27,7 +28,20 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
}
|
||||
|
||||
// The routes that render a built screen rather than a placeholder page.
|
||||
const SCREENS = { '/settings': true, '/settings/ai': true, '/settings/household': true }
|
||||
const SCREENS = {
|
||||
'/onboarding': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
'/settings/regulars': true,
|
||||
}
|
||||
|
||||
// SIGNED_IN answers what every route reads first: the session, then the
|
||||
// household settings, onboarding finished.
|
||||
const SIGNED_IN = {
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
}
|
||||
|
||||
describe('App routes', () => {
|
||||
it('registers every handoff route plus the extra settings pages', () => {
|
||||
@@ -54,7 +68,7 @@ describe('App routes', () => {
|
||||
'%s renders its placeholder page inside the shell',
|
||||
async (path, title) => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt(path)
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: title })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
@@ -77,7 +91,7 @@ describe('App routes', () => {
|
||||
it('/settings/ai renders the AI assistant screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
...SIGNED_IN,
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
|
||||
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||
@@ -90,21 +104,73 @@ describe('App routes', () => {
|
||||
|
||||
it('/settings/household renders the household settings screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
})
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt('/settings/household')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
|
||||
})
|
||||
|
||||
it('/settings/regulars renders the Regular dinners screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [dinnerBody('d1', 'Beef tacos')] }),
|
||||
})
|
||||
renderAt('/settings/regulars')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Regular dinners' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App first-run redirect', () => {
|
||||
const FIRST_RUN = {
|
||||
'/api/v1/me': () => jsonResponse(200, ME),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }),
|
||||
}
|
||||
|
||||
it.each(routes.filter(({ path }) => path !== '/onboarding').map(({ path }) => path.replace(':id', 'abc123')))(
|
||||
'%s redirects to /onboarding while onboarding is not finished',
|
||||
async (path) => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(FIRST_RUN)
|
||||
renderAt(path)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('stays on an onboarding step', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({ ...FIRST_RUN, '/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [] }) })
|
||||
renderAt('/onboarding/regulars')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the routes once onboarding is finished', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt('/plan')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/plan')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App bootstrap', () => {
|
||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||
mockViewport(390)
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/plan')
|
||||
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||
@@ -138,7 +204,7 @@ 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))
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
@@ -158,7 +224,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() {
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
const { unmount } = renderAt('/')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
unmount()
|
||||
@@ -226,12 +292,17 @@ describe('App offline bootstrap', () => {
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
const fetchMock = stubFetchByPath(SIGNED_IN)
|
||||
onLine = true
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('online'))
|
||||
})
|
||||
await vi.waitFor(() => expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' }))
|
||||
await vi.waitFor(() =>
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||
'/api/v1/me',
|
||||
'/api/v1/settings/household',
|
||||
]),
|
||||
)
|
||||
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
+15
-4
@@ -3,11 +3,14 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
|
||||
@@ -35,16 +38,24 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
'/settings/regulars': <RegularsSettingsPage />,
|
||||
}
|
||||
|
||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||
// Onboarding is a full screen of its own, outside the shell; until it is
|
||||
// finished, OnboardingGate sends every other route to it.
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
{routes.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
|
||||
))}
|
||||
<Route element={<OnboardingGate />}>
|
||||
<Route path={`${ONBOARDING_PATH}/*`} element={<OnboardingPage />} />
|
||||
<Route element={<AppShell />}>
|
||||
{routes
|
||||
.filter(({ path }) => path !== ONBOARDING_PATH)
|
||||
.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={screens[path] ?? <PlaceholderPage title={title} />} />
|
||||
))}
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -41,6 +41,11 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
// LoadingScreen is the full-screen skeleton shown while MealQuest loads.
|
||||
export function LoadingScreen() {
|
||||
return (
|
||||
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
|
||||
<div className={styles.loading}>
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
|
||||
export const DINNERS_PATH = '/api/v1/dinners'
|
||||
export const REGULARS_PATH = `${DINNERS_PATH}?status=regular`
|
||||
|
||||
// GROUNDING_POLL_MS is how often the Regulars are read again while any of
|
||||
// them is still finding its recipe.
|
||||
export const GROUNDING_POLL_MS = 3000
|
||||
|
||||
// Methods and Difficulties are the values the dinners API allows.
|
||||
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
|
||||
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
|
||||
const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
|
||||
|
||||
export type Method = (typeof METHODS)[number]
|
||||
export type Difficulty = (typeof DIFFICULTIES)[number]
|
||||
export type GroundingStatus = (typeof GROUNDING_STATUSES)[number]
|
||||
|
||||
// Regular is the part of a dinner the Regulars screens read. photo_id is
|
||||
// the stored photo's id, from photo_url. pending is true while an
|
||||
// optimistic add waits for the server; its id is not the server's.
|
||||
export type Regular = {
|
||||
id: string
|
||||
name: string
|
||||
method: Method | null
|
||||
minutes_total: number | null
|
||||
difficulty: Difficulty | null
|
||||
is_favorite: boolean
|
||||
photo_id: string | undefined
|
||||
grounding_status: GroundingStatus
|
||||
pending?: true
|
||||
}
|
||||
|
||||
// NewRegular is a POST /api/v1/dinners body: the household's name for the
|
||||
// dinner, with the recipe link to ground it in when one was pasted. A
|
||||
// dinner is never named from its link, which may carry private details.
|
||||
export type NewRegular = { name: string; recipe_url?: string }
|
||||
|
||||
// RegularPatch is a PATCH /api/v1/dinners/{id} body; every field is optional.
|
||||
export type RegularPatch = Partial<{ name: string; method: Method; minutes_total: number; difficulty: Difficulty }>
|
||||
|
||||
// ManualRecipe is a recipe the household types in; the server needs its
|
||||
// minutes and method.
|
||||
export type ManualRecipe = { ingredients: string[]; steps: string[]; minutes: number; method: Method }
|
||||
|
||||
// RecipeFix is a POST /api/v1/dinners/{id}/ground body: exactly one way to
|
||||
// ground the dinner.
|
||||
export type RecipeFix = { candidate_id: string } | { recipe_url: string } | { manual: ManualRecipe }
|
||||
|
||||
// RecipeCandidate is one recipe page a dinner could be grounded in.
|
||||
export type RecipeCandidate = {
|
||||
candidate_id: string
|
||||
title: string
|
||||
site_name: string
|
||||
photo_id: string | undefined
|
||||
minutes: number | null
|
||||
}
|
||||
|
||||
// CachedRegular is a Regular as the cache holds it. removing is true while
|
||||
// an optimistic removal waits for the server: the entry keeps its place, so
|
||||
// a refused removal returns exactly there whatever else changed meanwhile.
|
||||
type CachedRegular = Regular & { removing?: true }
|
||||
|
||||
function shown(list: CachedRegular[]): Regular[] {
|
||||
return list.filter((regular) => !regular.removing)
|
||||
}
|
||||
|
||||
function restored(regular: CachedRegular): CachedRegular {
|
||||
const copy = { ...regular }
|
||||
delete copy.removing
|
||||
return copy
|
||||
}
|
||||
|
||||
// REGULARS_KEY prefixes every Regulars query, so a change refreshes them all
|
||||
// (the settings home's count included).
|
||||
export const REGULARS_KEY = ['dinners', 'regular'] as const
|
||||
const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const
|
||||
const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const
|
||||
|
||||
function oneOf<T extends string>(where: string, field: string, values: readonly T[], value: unknown): T {
|
||||
if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) {
|
||||
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`)
|
||||
}
|
||||
return value as T
|
||||
}
|
||||
|
||||
function nullable<T>(value: unknown, parse: (value: unknown) => T): T | null {
|
||||
return value === null ? null : parse(value)
|
||||
}
|
||||
|
||||
function wholeNumber(where: string, field: string, value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) throw new Error(`${where}: ${field} is not a whole number`)
|
||||
return value
|
||||
}
|
||||
|
||||
const PHOTO_URL = /^\/media\/photos\/([^/]+)-400\.jpg$/
|
||||
|
||||
// photoId reads the photo id out of a photo_url, which the server always
|
||||
// sends as /media/photos/{id}-400.jpg or null.
|
||||
function photoId(where: string, value: unknown): string | undefined {
|
||||
if (value === null) return undefined
|
||||
const match = typeof value === 'string' ? PHOTO_URL.exec(value) : null
|
||||
if (!match?.[1]) throw new Error(`${where}: photo_url ${JSON.stringify(value)} is not a stored photo`)
|
||||
return decodeURIComponent(match[1])
|
||||
}
|
||||
|
||||
function parseDinner(where: string, body: unknown): Regular {
|
||||
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||
throw new Error(`${where}: response is not a dinner`)
|
||||
}
|
||||
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
|
||||
return {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
method: nullable(body.method, (v) => oneOf(where, 'method', METHODS, v)),
|
||||
minutes_total: nullable(body.minutes_total, (v) => wholeNumber(where, 'minutes_total', v)),
|
||||
difficulty: nullable(body.difficulty, (v) => oneOf(where, 'difficulty', DIFFICULTIES, v)),
|
||||
is_favorite: body.is_favorite,
|
||||
photo_id: photoId(where, body.photo_url),
|
||||
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
|
||||
}
|
||||
}
|
||||
|
||||
function parseCandidate(where: string, body: unknown): RecipeCandidate {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.candidate_id !== 'string' ||
|
||||
typeof body.title !== 'string' ||
|
||||
typeof body.site_name !== 'string'
|
||||
) {
|
||||
throw new Error(`${where}: response is not a recipe candidate`)
|
||||
}
|
||||
return {
|
||||
candidate_id: body.candidate_id,
|
||||
title: body.title,
|
||||
site_name: body.site_name,
|
||||
photo_id: photoId(where, body.photo_url),
|
||||
minutes: nullable(body.minutes, (v) => wholeNumber(where, 'minutes', v)),
|
||||
}
|
||||
}
|
||||
|
||||
function dinnerPath(id: string): string {
|
||||
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
// useRegulars is the household's Regulars, in the server's order. While any
|
||||
// of them is still finding its recipe it is read again every
|
||||
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
|
||||
// reads it again anyway), so a poll never overwrites a change being saved.
|
||||
// Watching the changes in flight re-renders the hook as each one starts and
|
||||
// settles, which pauses and resumes the poll.
|
||||
export function useRegulars() {
|
||||
const { apiFetch } = useApi()
|
||||
const changing = useIsMutating({ mutationKey: REGULARS_MUTATION_KEY }) > 0
|
||||
return useQuery({
|
||||
queryKey: REGULARS_LIST_KEY,
|
||||
queryFn: async () => {
|
||||
const where = `GET ${REGULARS_PATH}`
|
||||
const body = await apiFetch<unknown>(REGULARS_PATH)
|
||||
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
||||
return body.dinners.map((dinner): CachedRegular => parseDinner(where, dinner))
|
||||
},
|
||||
select: shown,
|
||||
refetchInterval: (query) => {
|
||||
const list = query.state.data
|
||||
if (list === undefined || changing) return false
|
||||
return list.some((regular) => regular.grounding_status === 'pending') ? GROUNDING_POLL_MS : false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// changeOptimistically stops Regulars reads in flight, so they cannot
|
||||
// overwrite the optimistic list, then applies change to it.
|
||||
async function changeOptimistically(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) {
|
||||
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
|
||||
setRegulars(queryClient, change)
|
||||
}
|
||||
|
||||
function setRegulars(queryClient: QueryClient, change: (list: CachedRegular[]) => CachedRegular[]) {
|
||||
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) => change(list ?? []))
|
||||
}
|
||||
|
||||
// replace puts the server's answer for a dinner in its place in the list.
|
||||
function replace(queryClient: QueryClient, dinner: Regular) {
|
||||
setRegulars(queryClient, (list) => list.map((regular) => (regular.id === dinner.id ? dinner : regular)))
|
||||
}
|
||||
|
||||
// settle refetches the Regulars once the last change in flight is done, so
|
||||
// an earlier change's refetch never drops a later change's optimistic entry.
|
||||
function settle(queryClient: QueryClient) {
|
||||
if (queryClient.isMutating({ mutationKey: REGULARS_MUTATION_KEY }) === 1) {
|
||||
void queryClient.invalidateQueries({ queryKey: REGULARS_KEY })
|
||||
}
|
||||
}
|
||||
|
||||
let pendingIds = 0
|
||||
|
||||
// useAddRegular adds a Regular by name, with or without a recipe link (POST
|
||||
// /api/v1/dinners). It shows in the list at once under its name and is
|
||||
// taken out again if the server refuses it.
|
||||
export function useAddRegular() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async (add: NewRegular) =>
|
||||
parseDinner(`POST ${DINNERS_PATH}`, await apiFetch<unknown>(DINNERS_PATH, { method: 'POST', body: add })),
|
||||
onMutate: async (add) => {
|
||||
const id = `pending-${++pendingIds}`
|
||||
const optimistic: CachedRegular = {
|
||||
id,
|
||||
name: add.name,
|
||||
method: null,
|
||||
minutes_total: null,
|
||||
difficulty: null,
|
||||
is_favorite: false,
|
||||
photo_id: undefined,
|
||||
grounding_status: 'pending',
|
||||
pending: true,
|
||||
}
|
||||
await changeOptimistically(queryClient, (list) => [...list, optimistic])
|
||||
return { id }
|
||||
},
|
||||
onSuccess: (dinner, _add, context) => {
|
||||
setRegulars(queryClient, (list) => list.map((regular) => (regular.id === context.id ? dinner : regular)))
|
||||
},
|
||||
onError: (_error, _add, context) => {
|
||||
if (context === undefined) return
|
||||
setRegulars(queryClient, (list) => list.filter((regular) => regular.id !== context.id))
|
||||
},
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
// useRemoveRegular archives a Regular (DELETE /api/v1/dinners/{id}). It
|
||||
// leaves the list at once and is put back in its place if the server
|
||||
// refuses.
|
||||
export function useRemoveRegular() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async (regular: Regular) => {
|
||||
await apiFetch<unknown>(dinnerPath(regular.id), { method: 'DELETE' })
|
||||
},
|
||||
onMutate: async (regular) => {
|
||||
await changeOptimistically(queryClient, (list) =>
|
||||
list.map((r): CachedRegular => (r.id === regular.id ? { ...r, removing: true } : r)),
|
||||
)
|
||||
},
|
||||
onSuccess: (_data, regular) => {
|
||||
setRegulars(queryClient, (list) => list.filter((r) => r.id !== regular.id))
|
||||
},
|
||||
onError: (_error, regular) => {
|
||||
setRegulars(queryClient, (list) => list.map((r) => (r.id === regular.id ? restored(r) : r)))
|
||||
},
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
// useEditRegular saves changes to a Regular (PATCH /api/v1/dinners/{id})
|
||||
// and shows the server's answer once it is saved.
|
||||
export function useEditRegular() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async ({ id, patch }: { id: string; patch: RegularPatch }) =>
|
||||
parseDinner(`PATCH ${DINNERS_PATH}/{id}`, await apiFetch<unknown>(dinnerPath(id), { method: 'PATCH', body: patch })),
|
||||
onSuccess: (dinner) => replace(queryClient, dinner),
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
// useToggleFavorite toggles a Regular's favorite heart (POST
|
||||
// /api/v1/dinners/{id}/favorite). The heart changes at once and changes
|
||||
// back if the server refuses.
|
||||
export function useToggleFavorite() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const set = (id: string, is_favorite: boolean) =>
|
||||
setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r)))
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async (regular: Regular) =>
|
||||
parseDinner(
|
||||
`POST ${DINNERS_PATH}/{id}/favorite`,
|
||||
await apiFetch<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
|
||||
),
|
||||
onMutate: async (regular) => {
|
||||
await changeOptimistically(queryClient, (list) =>
|
||||
list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)),
|
||||
)
|
||||
},
|
||||
onSuccess: (dinner) => replace(queryClient, dinner),
|
||||
onError: (_error, regular) => set(regular.id, regular.is_favorite),
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
// useGroundRegular fixes a Regular's recipe (POST /api/v1/dinners/{id}/ground)
|
||||
// with a candidate, a pasted link or a recipe typed in. A candidate or link
|
||||
// is read in the request, which can take up to a minute and a half.
|
||||
export function useGroundRegular() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async ({ id, fix }: { id: string; fix: RecipeFix }) =>
|
||||
parseDinner(
|
||||
`POST ${DINNERS_PATH}/{id}/ground`,
|
||||
await apiFetch<unknown>(`${dinnerPath(id)}/ground`, { method: 'POST', body: fix }),
|
||||
),
|
||||
onSuccess: (dinner) => replace(queryClient, dinner),
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
// useRecipeCandidates is up to three recipe pages the dinner could be
|
||||
// grounded in, best first (GET /api/v1/dinners/{id}/recipe-candidates).
|
||||
export function useRecipeCandidates(id: string) {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: ['dinners', 'candidates', id],
|
||||
queryFn: async () => {
|
||||
const path = `${dinnerPath(id)}/recipe-candidates`
|
||||
const where = `GET ${DINNERS_PATH}/{id}/recipe-candidates`
|
||||
const body = await apiFetch<unknown>(path)
|
||||
if (!isRecord(body) || !Array.isArray(body.candidates)) throw new Error(`${where}: response has no candidates list`)
|
||||
return body.candidates.map((candidate) => parseCandidate(where, candidate))
|
||||
},
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import cases from '../../../internal/ai/harness/testdata/dedupe_cases.json'
|
||||
import { normalizeName, similarNames } from './names.ts'
|
||||
|
||||
// The cases internal/ai/harness's tests run against Normalize and Similar.
|
||||
describe('dinner names, as the server compares them', () => {
|
||||
it('has cases to run', () => {
|
||||
expect(cases.normalize.length).toBeGreaterThan(0)
|
||||
expect(cases.similar.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each(cases.normalize)('normalizes "$name" to "$want"', ({ name, want }) => {
|
||||
expect(normalizeName(name)).toBe(want)
|
||||
})
|
||||
|
||||
it.each(cases.similar)('"$a" and "$b" similar: $want', ({ a, b, want }) => {
|
||||
expect(similarNames(a, b)).toBe(want)
|
||||
expect(similarNames(b, a)).toBe(want)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
// The PWA's copy of the server's dinner-name rules (internal/ai/harness
|
||||
// Normalize and Similar), so a screen can tell beforehand that the dinners
|
||||
// API would refuse a name as a duplicate. The server still decides.
|
||||
// internal/ai/harness/testdata/dedupe_cases.json is run against both.
|
||||
|
||||
import folds from './letterFolds.json'
|
||||
|
||||
// SIMILAR_THRESHOLD is harness.SimilarThreshold.
|
||||
const SIMILAR_THRESHOLD = 0.85
|
||||
|
||||
// fillerWords is harness's fillerWords.
|
||||
const fillerWords = new Set(['easy', 'best', 'homemade', 'quick', 'simple', 'the', 'a', 'recipe', 'classic'])
|
||||
|
||||
// letterFolds is harness's letterFolds: gen_folds.py writes both tables,
|
||||
// and harness's TestLetterFoldsMatchPWA fails when they differ.
|
||||
const letterFolds: Record<string, string> = folds
|
||||
|
||||
const combiningMark = /\p{Mn}/u
|
||||
const wordChar = /[\p{L}\p{Nd}]/u
|
||||
|
||||
// normalizeName is harness.Normalize: lowercased, accents dropped, letters
|
||||
// folded, apostrophes removed, other punctuation turned into spaces and
|
||||
// filler words dropped. "Easy Chicken Tikka-Masala!" is
|
||||
// "chicken tikka masala".
|
||||
export function normalizeName(name: string): string {
|
||||
let out = ''
|
||||
// Lowercased letter by letter, as Go's strings.ToLower does, so no
|
||||
// letter is lowered by its context (a final Σ is σ, not ς).
|
||||
for (const letter of name) {
|
||||
for (const ch of letter.toLowerCase()) {
|
||||
if (combiningMark.test(ch) || ch === "'" || ch === '’') continue
|
||||
const fold = letterFolds[ch]
|
||||
if (fold !== undefined) out += fold
|
||||
else if (wordChar.test(ch)) out += ch
|
||||
else out += ' '
|
||||
}
|
||||
}
|
||||
return out
|
||||
.split(' ')
|
||||
.filter((word) => word !== '' && !fillerWords.has(word))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function words(name: string): Set<string> {
|
||||
const normalized = normalizeName(name)
|
||||
return new Set(normalized === '' ? [] : normalized.split(' '))
|
||||
}
|
||||
|
||||
// similarNames is harness.Similar: whether a and b name the same dinner, by
|
||||
// the share of the words of their normalized forms they have in common.
|
||||
export function similarNames(a: string, b: string): boolean {
|
||||
const left = words(a)
|
||||
const right = words(b)
|
||||
if (left.size === 0 || right.size === 0) return false
|
||||
let shared = 0
|
||||
for (const word of left) if (right.has(word)) shared++
|
||||
return shared / (left.size + right.size - shared) >= SIMILAR_THRESHOLD
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
// Refusal is one change the server turned down. key is unique to that
|
||||
// attempt, so two attempts of the same change in flight each keep their own
|
||||
// refusal. change names what was tried, so trying it again once it has been
|
||||
// refused clears the earlier refusal and no other.
|
||||
export type Refusal = { key: number; change: string; text: string }
|
||||
|
||||
// useRefusals keeps each change's own refusal: the mutation hooks only
|
||||
// report their latest change, which would hide a refusal while another
|
||||
// change is in flight. track(change, what, answer) clears change's earlier
|
||||
// refusal and records "Could not {what}: …" if answer rejects.
|
||||
export function useRefusals() {
|
||||
const [refusals, setRefusals] = useState<Refusal[]>([])
|
||||
const attempts = useRef(0)
|
||||
|
||||
function track(change: string, what: string, answer: Promise<unknown>) {
|
||||
const key = ++attempts.current
|
||||
setRefusals((all) => all.filter((refusal) => refusal.change !== change))
|
||||
answer.catch((error: Error) => {
|
||||
setRefusals((all) => [...all, { key, change, text: `Could not ${what}: ${error.message}` }])
|
||||
})
|
||||
}
|
||||
|
||||
return { refusals, track }
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import App from '../App.tsx'
|
||||
import { AuthBootstrap } from '../AuthBootstrap.tsx'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { NotificationsStep, notificationsUnavailable, type EnableNotifications } from './NotificationsStep.tsx'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
const IPHONE_SAFARI =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
|
||||
|
||||
const COMPLETED_AT = '2026-10-06T21:30:00.000Z'
|
||||
|
||||
// requestPermission stands in for the browser's Notification API, which
|
||||
// jsdom lacks, so a test can prove nothing asks for permission.
|
||||
let requestPermission: Mock<() => Promise<NotificationPermission>>
|
||||
|
||||
beforeEach(() => {
|
||||
requestPermission = vi.fn<() => Promise<NotificationPermission>>(() => Promise.resolve('granted'))
|
||||
vi.stubGlobal('Notification', { permission: 'default', requestPermission })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(navigator, 'maxTouchPoints')
|
||||
Reflect.deleteProperty(navigator, 'standalone')
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// A PATCH the fake server holds until the test answers it.
|
||||
type Held = { body: unknown; answer: (response: Response) => void }
|
||||
|
||||
// stubFirstRunServer stands in for the server on first run: signed in,
|
||||
// onboarding not finished. Each PATCH of the household settings is held in
|
||||
// `held` until the test answers it.
|
||||
function stubFirstRunServer() {
|
||||
const held: Held[] = []
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const path = String(input)
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
if (method === 'GET' && path === '/api/v1/settings/household') {
|
||||
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
|
||||
}
|
||||
if (method === 'PATCH' && path === '/api/v1/settings/household') {
|
||||
return new Promise<Response>((resolve) => {
|
||||
held.push({ body: JSON.parse(String(init?.body)), answer: resolve })
|
||||
})
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${method} ${path}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, held }
|
||||
}
|
||||
|
||||
function completed(request: Held) {
|
||||
request.answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: COMPLETED_AT }))
|
||||
}
|
||||
|
||||
// renderApp renders the whole app at step 3, so the first-run redirect is
|
||||
// what decides where finishing lands.
|
||||
async function renderApp() {
|
||||
mockViewport(390)
|
||||
const server = stubFirstRunServer()
|
||||
const api = createApiClient(
|
||||
fakeLocation('/onboarding/notifications'),
|
||||
createNetworkStatus(window),
|
||||
createVerifiedSession(localStorage),
|
||||
)
|
||||
render(
|
||||
<InDataRouter path="/onboarding/notifications">
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</InDataRouter>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
|
||||
return server
|
||||
}
|
||||
|
||||
// renderStep renders step 3 alone with the given enableNotifications, and a
|
||||
// Home that says it was reached. AuthBootstrap reads /api/v1/me for the CSRF
|
||||
// token every write needs.
|
||||
async function renderStep(enableNotifications?: EnableNotifications) {
|
||||
const server = stubFirstRunServer()
|
||||
render(
|
||||
<InDataRouter path="/onboarding/notifications">
|
||||
<ApiProviders>
|
||||
<AuthBootstrap>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/onboarding/notifications"
|
||||
element={<NotificationsStep enableNotifications={enableNotifications} />}
|
||||
/>
|
||||
<Route path="/" element={<h1>Home</h1>} />
|
||||
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
||||
</Routes>
|
||||
</AuthBootstrap>
|
||||
</ApiProviders>
|
||||
</InDataRouter>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Stay in the loop' })
|
||||
return server
|
||||
}
|
||||
|
||||
function turnOn() {
|
||||
return screen.getByRole('button', { name: 'Turn on notifications' })
|
||||
}
|
||||
|
||||
function later() {
|
||||
return screen.getByRole('button', { name: 'Maybe later' })
|
||||
}
|
||||
|
||||
describe('onboarding step 3', () => {
|
||||
it('explains what MealQuest notifies about, with all three progress pills filled', async () => {
|
||||
await renderApp()
|
||||
|
||||
expect(screen.getByText('Step 3 of 3')).toBeInTheDocument()
|
||||
const pills = Array.from(document.getElementsByClassName(styles.pill!))
|
||||
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, true])
|
||||
const topics = within(screen.getByRole('list')).getAllByRole('listitem')
|
||||
expect(topics.map((topic) => topic.textContent)).toEqual([
|
||||
expect.stringMatching(/^Plan ready/),
|
||||
expect.stringMatching(/^Supply run ready/),
|
||||
expect.stringMatching(/^Tonight's dinner/),
|
||||
expect.stringMatching(/^New Side quests/),
|
||||
])
|
||||
expect(turnOn()).toBeEnabled()
|
||||
expect(later()).toBeEnabled()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('never asks for notification permission on render', async () => {
|
||||
await renderApp()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sizes both actions as tap targets and fixes them to the bottom', async () => {
|
||||
await renderApp()
|
||||
expectMinHitArea(turnOn())
|
||||
expectMinHitArea(later())
|
||||
expect(declaredStyle(turnOn()).height).toBe('52px')
|
||||
expect(declaredStyle(later()).height).toBe('52px')
|
||||
expect(declaredStyle(turnOn().parentElement!.parentElement!).position).toBe('fixed')
|
||||
})
|
||||
|
||||
it('shows no install hint outside iOS Safari', async () => {
|
||||
await renderApp()
|
||||
expect(screen.queryByText('Tap Share, then Add to Home Screen to get notifications')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the install hint in an iOS Safari tab', async () => {
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(IPHONE_SAFARI)
|
||||
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: false })
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query }) as MediaQueryList)
|
||||
await renderStep()
|
||||
expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications')
|
||||
})
|
||||
|
||||
it('"Maybe later" finishes onboarding and goes Home, ending the first-run redirect', async () => {
|
||||
const server = await renderApp()
|
||||
fireEvent.click(later())
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
expect(turnOn()).toBeDisabled()
|
||||
expect(later()).toBeDisabled()
|
||||
completed(server.held[0]!)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"Turn on notifications" finishes onboarding and goes Home while notifications are unavailable', async () => {
|
||||
const server = await renderApp()
|
||||
fireEvent.click(turnOn())
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
completed(server.held[0]!)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"Turn on notifications" asks the injected enableNotifications first, then finishes', async () => {
|
||||
let answer: (result: 'enabled') => void = () => {}
|
||||
const enable = vi.fn<EnableNotifications>(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve
|
||||
}),
|
||||
)
|
||||
const server = await renderStep(enable)
|
||||
expect(enable).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(turnOn())
|
||||
expect(enable).toHaveBeenCalledOnce()
|
||||
expect(turnOn()).toBeDisabled()
|
||||
expect(later()).toBeDisabled()
|
||||
// Onboarding is not finished until enableNotifications has answered.
|
||||
await Promise.resolve()
|
||||
expect(server.held).toHaveLength(0)
|
||||
|
||||
answer('enabled')
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Maybe later" does not ask the injected enableNotifications', async () => {
|
||||
const enable = vi.fn<EnableNotifications>(() => Promise.resolve('enabled'))
|
||||
const server = await renderStep(enable)
|
||||
fireEvent.click(later())
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(enable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says why and stays when enableNotifications fails', async () => {
|
||||
const server = await renderStep(() => Promise.reject(new Error('push service down')))
|
||||
fireEvent.click(turnOn())
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push service down')
|
||||
expect(server.held).toHaveLength(0)
|
||||
expect(turnOn()).toBeEnabled()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says why and stays when the server refuses to finish onboarding, then finishes on retry', async () => {
|
||||
const server = await renderApp()
|
||||
fireEvent.click(later())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
||||
expect(later()).toBeEnabled()
|
||||
|
||||
fireEvent.click(later())
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
completed(server.held[1]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboarding step 3 with too few Regulars', () => {
|
||||
it('says why and offers a way back to add Regulars', async () => {
|
||||
const server = await renderStep()
|
||||
fireEvent.click(later())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(
|
||||
jsonResponse(400, {
|
||||
error: {
|
||||
code: 'invalid_settings',
|
||||
message: 'invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Could not finish setting up: invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
|
||||
)
|
||||
const addRegulars = screen.getByRole('button', { name: 'Add Regulars' })
|
||||
expectMinHitArea(addRegulars)
|
||||
fireEvent.click(addRegulars)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Regulars step' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no way back for any other refusal', async () => {
|
||||
const server = await renderStep()
|
||||
fireEvent.click(later())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
|
||||
expect(screen.queryByRole('button', { name: 'Add Regulars' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('notificationsUnavailable', () => {
|
||||
it('answers "unavailable" without asking for permission', async () => {
|
||||
await expect(notificationsUnavailable()).resolves.toBe('unavailable')
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from 'react'
|
||||
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { InstallHint } from '../pwa/InstallHint.tsx'
|
||||
import { needsMoreRegulars, useCompleteOnboarding } from '../settings/api.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
// NotificationsResult is how asking to turn on notifications ended.
|
||||
export type NotificationsResult = 'enabled' | 'denied' | 'unavailable'
|
||||
|
||||
export type EnableNotifications = () => Promise<NotificationsResult>
|
||||
|
||||
// TODO(Feature 19): Web Push. Until it is wired, notifications cannot be
|
||||
// turned on, and nothing asks the browser for permission.
|
||||
export function notificationsUnavailable(): Promise<NotificationsResult> {
|
||||
return Promise.resolve('unavailable')
|
||||
}
|
||||
|
||||
const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
|
||||
{
|
||||
icon: CalendarDays,
|
||||
name: 'Plan ready',
|
||||
meaning: "The week's Draft board is ready for the household to adjust.",
|
||||
},
|
||||
{
|
||||
icon: ShoppingBasket,
|
||||
name: 'Supply run ready',
|
||||
meaning: "The week's shopping list, grouped by dinner, is ready to shop.",
|
||||
},
|
||||
{
|
||||
icon: UtensilsCrossed,
|
||||
name: "Tonight's dinner",
|
||||
meaning: "A reminder before dinner of what's on the plan tonight.",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
name: 'New Side quests',
|
||||
meaning: 'Fresh dinners to Try it or Pass, picked from your Regulars.',
|
||||
},
|
||||
]
|
||||
|
||||
export type NotificationsStepProps = {
|
||||
// Asks to turn on notifications when the household taps "Turn on
|
||||
// notifications" — never on render.
|
||||
enableNotifications?: EnableNotifications
|
||||
}
|
||||
|
||||
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
|
||||
// iOS install hint, and finishing onboarding. Either button records that
|
||||
// onboarding is finished and goes Home. When the server refuses because the
|
||||
// household has fewer than three Regulars, the reason shows with a way back
|
||||
// to step 2 to add them.
|
||||
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
|
||||
const navigate = useNavigate()
|
||||
const complete = useCompleteOnboarding()
|
||||
const [enabling, setEnabling] = useState(false)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
const [fewRegulars, setFewRegulars] = useState(false)
|
||||
const busy = enabling || complete.isPending
|
||||
|
||||
async function finish() {
|
||||
try {
|
||||
await complete.mutateAsync()
|
||||
} catch (error) {
|
||||
setProblem(`Could not finish setting up: ${(error as Error).message}`)
|
||||
setFewRegulars(needsMoreRegulars(error as Error))
|
||||
return
|
||||
}
|
||||
void navigate('/', { replace: true })
|
||||
}
|
||||
|
||||
function clearProblem() {
|
||||
setProblem(null)
|
||||
setFewRegulars(false)
|
||||
}
|
||||
|
||||
async function turnOn() {
|
||||
clearProblem()
|
||||
setEnabling(true)
|
||||
try {
|
||||
await enableNotifications()
|
||||
} catch (error) {
|
||||
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
|
||||
return
|
||||
} finally {
|
||||
setEnabling(false)
|
||||
}
|
||||
await finish()
|
||||
}
|
||||
|
||||
function later() {
|
||||
clearProblem()
|
||||
void finish()
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
step={3}
|
||||
title="Stay in the loop"
|
||||
action={
|
||||
<div className={styles.actions}>
|
||||
<Button className={styles.action} disabled={busy} onClick={() => void turnOn()}>
|
||||
Turn on notifications
|
||||
</Button>
|
||||
<Button variant="outline" className={styles.action} disabled={busy} onClick={later}>
|
||||
Maybe later
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className={styles.helper}>MealQuest can let the household know when there's something to look at:</p>
|
||||
<ul className={styles.terms}>
|
||||
{topics.map(({ icon, name, meaning }) => (
|
||||
<li key={name} className={styles.term}>
|
||||
<span className={styles.termIcon}>
|
||||
<Icon icon={icon} size="nav" />
|
||||
</span>
|
||||
<p className={styles.termText}>
|
||||
<span className={styles.termName}>{name}</span>
|
||||
{meaning}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.hint}>
|
||||
<InstallHint />
|
||||
</div>
|
||||
{problem && (
|
||||
<p role="alert" className={styles.problem}>
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
{fewRegulars && (
|
||||
<Button variant="outline" className={styles.recover} onClick={() => void navigate('/onboarding/regulars')}>
|
||||
Add Regulars
|
||||
</Button>
|
||||
)}
|
||||
</OnboardingStep>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/* Onboarding, handoff mock 1h: a full screen outside the app shell with
|
||||
the step's action fixed to the bottom. */
|
||||
.screen {
|
||||
min-height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
/* Room for the fixed 52 px action and its padding. */
|
||||
padding: var(--space-6) var(--space-4) calc(52px + 2 * var(--space-6));
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.column {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.filled {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 30px 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 8px 0 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.helper {
|
||||
margin: 12px 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: var(--space-4) var(--space-4) var(--space-6);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
height: 52px;
|
||||
margin: 0 auto;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
/* Step 3: two stacked actions. */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Room for the second fixed action and the gap above it. */
|
||||
.screen:has(.actions) {
|
||||
padding-bottom: calc(2 * 52px + var(--space-3) + 2 * var(--space-6));
|
||||
}
|
||||
|
||||
/* Step 1: what MealQuest's words mean; step 3: what it notifies about. */
|
||||
.terms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin: var(--space-6) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.term {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.termIcon {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-200);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.termText {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.termName {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Step 2: the add pill, with the "+" inside it. */
|
||||
.add {
|
||||
position: relative;
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
/* Beats the TextInput's own padding whatever order the sheets load in. */
|
||||
.add .addInput {
|
||||
width: 100%;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
/* 44 px hit box around the 36 px grape face, inset 2 px in the 48 px pill. */
|
||||
.addButton {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addButton:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.addFace {
|
||||
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);
|
||||
transition: transform 100ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.addButton:disabled .addFace {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.addButton:active:not(:disabled) .addFace {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
margin: var(--space-6) 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border: 1.5px dashed var(--color-neutral-400);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pick:active {
|
||||
background: var(--color-neutral-200);
|
||||
}
|
||||
|
||||
.problem {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
/* Step 3: back to step 2 when the server wants more Regulars. */
|
||||
.recover {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Step 3: the iOS install hint, when it shows. */
|
||||
.hint {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.hint:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.addFace {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { LoadingScreen } from '../AuthBootstrap.tsx'
|
||||
import { useHouseholdSettings } from '../settings/api.ts'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import styles from '../pages/FullScreen.module.css'
|
||||
|
||||
export const ONBOARDING_PATH = '/onboarding'
|
||||
|
||||
function inOnboarding(pathname: string): boolean {
|
||||
return pathname === ONBOARDING_PATH || pathname.startsWith(`${ONBOARDING_PATH}/`)
|
||||
}
|
||||
|
||||
// OnboardingGate reads the household settings before any page: while
|
||||
// onboarding_completed_at is null, every route outside /onboarding
|
||||
// redirects there.
|
||||
//
|
||||
// The service worker never caches the settings, so offline on the saved
|
||||
// pages (see AuthBootstrap) they cannot be read: the saved pages open as they
|
||||
// are, since onboarding needs the server anyway, and the settings are read
|
||||
// again when the browser is back online.
|
||||
export function OnboardingGate() {
|
||||
const api = useApi()
|
||||
const { pathname } = useLocation()
|
||||
const settings = useHouseholdSettings()
|
||||
const offline = settings.isError && settings.error instanceof TypeError && api.session.verified()
|
||||
const { refetch } = settings
|
||||
|
||||
useEffect(() => {
|
||||
if (!offline) return
|
||||
return api.network.subscribe(() => {
|
||||
if (!api.network.offline()) void refetch()
|
||||
})
|
||||
}, [offline, api, refetch])
|
||||
|
||||
if (settings.data !== undefined) {
|
||||
if (settings.data.onboarding_completed_at === null && !inOnboarding(pathname)) {
|
||||
return <Navigate to={ONBOARDING_PATH} replace />
|
||||
}
|
||||
return <Outlet />
|
||||
}
|
||||
if (offline) return <Outlet />
|
||||
if (settings.isError) {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<ErrorBanner onRetry={() => void refetch()}>MealQuest could not load: {settings.error.message}</ErrorBanner>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return <LoadingScreen />
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import App from '../App.tsx'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea, hitArea } from '../test/hitArea.ts'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
type Dinner = DinnerBody
|
||||
|
||||
// A request the fake server holds until the test answers it.
|
||||
type Held = { method: string; path: string; body: unknown; answer: (response: Response) => void }
|
||||
|
||||
// stubDinnersServer stands in for the server a household meets on first
|
||||
// run: signed in, onboarding not finished and the given Regulars. GET answers at once;
|
||||
// each POST and DELETE is held in `held` until the test answers it, so the
|
||||
// test sees the optimistic list first. Once failReads is set, every GET of
|
||||
// the Regulars fails, so the test sees the list the client keeps by itself.
|
||||
function stubDinnersServer(regulars: Dinner[] = []) {
|
||||
const held: Held[] = []
|
||||
const reads = { fail: false }
|
||||
const fetchMock: Mock<typeof fetch> = vi.fn<typeof fetch>((input, init) => {
|
||||
const path = String(input)
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
if (method === 'GET' && path === '/api/v1/settings/household') {
|
||||
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
|
||||
}
|
||||
if (method === 'GET' && path === '/api/v1/dinners?status=regular') {
|
||||
if (reads.fail) return Promise.resolve(errorResponse(500, 'internal'))
|
||||
return Promise.resolve(jsonResponse(200, { dinners: regulars }))
|
||||
}
|
||||
if ((method === 'POST' && path === '/api/v1/dinners') || (method === 'DELETE' && path.startsWith('/api/v1/dinners/'))) {
|
||||
return new Promise<Response>((resolve) => {
|
||||
held.push({ method, path, body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), answer: resolve })
|
||||
})
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${method} ${path}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, held, regulars, reads }
|
||||
}
|
||||
|
||||
// created answers a held POST /api/v1/dinners as the server does, adding the
|
||||
// Regular to the list later GETs read.
|
||||
function created(server: ReturnType<typeof stubDinnersServer>, request: Held, id: string) {
|
||||
const { name } = request.body as { name: string }
|
||||
server.regulars.push(dinnerBody(id, name))
|
||||
request.answer(jsonResponse(201, { ...dinnerBody(id, name, { grounding_status: 'pending' }), grounding_job_id: 'job-1' }))
|
||||
}
|
||||
|
||||
// renderAt renders the whole app, so /me supplies the CSRF token every
|
||||
// write needs and OnboardingGate reads the household settings.
|
||||
function renderAt(path: string) {
|
||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<InDataRouter path={path}>
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</InDataRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
async function regularsStep(regulars: Dinner[] = []) {
|
||||
const server = stubDinnersServer(regulars)
|
||||
renderAt('/onboarding/regulars')
|
||||
// Common picks show once the Regulars have loaded.
|
||||
await screen.findByRole('heading', { level: 2, name: 'Common picks' })
|
||||
expect(screen.getByRole('heading', { level: 2, name: `Your regulars · ${regulars.length}` })).toBeInTheDocument()
|
||||
return server
|
||||
}
|
||||
|
||||
function input() {
|
||||
return screen.getByRole('textbox', { name: 'Dinner name' })
|
||||
}
|
||||
|
||||
function regularsList() {
|
||||
return screen.getByRole('heading', { level: 2, name: /^Your regulars/ }).nextElementSibling as HTMLElement
|
||||
}
|
||||
|
||||
function regularNames() {
|
||||
return within(regularsList())
|
||||
.queryAllByRole('listitem')
|
||||
.map((item) => item.textContent)
|
||||
}
|
||||
|
||||
function next() {
|
||||
return screen.getByRole('button', { name: 'Next: notifications' })
|
||||
}
|
||||
|
||||
describe('onboarding step 1', () => {
|
||||
it('welcomes the household, explains the words and starts with step 2', async () => {
|
||||
stubDinnersServer()
|
||||
renderAt('/onboarding')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Step 1 of 3')).toBeInTheDocument()
|
||||
const terms = within(screen.getByRole('list')).getAllByRole('listitem')
|
||||
expect(terms.map((term) => term.textContent)).toEqual([
|
||||
expect.stringMatching(/^Regulars/),
|
||||
expect.stringMatching(/^Side quests/),
|
||||
expect.stringMatching(/^Quest log/),
|
||||
])
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: "Let's start" }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fills one progress pill of three', async () => {
|
||||
stubDinnersServer()
|
||||
renderAt('/onboarding')
|
||||
await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })
|
||||
const pills = Array.from(document.getElementsByClassName(styles.pill!))
|
||||
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, false, false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('onboarding step 2 (mock 1h)', () => {
|
||||
it('shows the step as the mock does', async () => {
|
||||
await regularsStep([dinnerBody('d1', 'Beef tacos')])
|
||||
|
||||
expect(screen.getByText('Step 2 of 3')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Add the dinners your household makes on repeat. MealQuest builds suggestions from these.'),
|
||||
).toBeInTheDocument()
|
||||
expect(input()).toHaveAttribute('placeholder', 'e.g. Chicken enchiladas')
|
||||
expect(regularNames()).toEqual(['Beef tacos'])
|
||||
expect(screen.getByRole('button', { name: 'Remove Beef tacos' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Common picks' })).toBeInTheDocument()
|
||||
// A common pick the household already has is not offered again.
|
||||
expect(screen.queryByRole('button', { name: 'Add Beef tacos' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Chili' })).toHaveTextContent('+Chili')
|
||||
|
||||
const pills = Array.from(document.getElementsByClassName(styles.pill!))
|
||||
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, false])
|
||||
expect(declaredStyle(pills[0]!).height).toBe('6px')
|
||||
})
|
||||
|
||||
it('does not offer a common pick the server would refuse as a duplicate of a Regular', async () => {
|
||||
await regularsStep([dinnerBody('d1', 'Homemade chili'), dinnerBody('d2', 'Easy Chicken Stir Fry')])
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add Chicken stir-fry' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sizes the input pill, "+" button, picks and Next as the mock does', async () => {
|
||||
await regularsStep()
|
||||
expect(hitArea(input()).height).toBe(48)
|
||||
const add = screen.getByRole('button', { name: 'Add Regular' })
|
||||
expectMinHitArea(add)
|
||||
expect(declaredStyle(add.firstElementChild!).width).toBe('36px')
|
||||
expect(declaredStyle(add.firstElementChild!).height).toBe('36px')
|
||||
expectMinHitArea(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
expect(declaredStyle(next()).height).toBe('52px')
|
||||
expect(declaredStyle(next().parentElement!).position).toBe('fixed')
|
||||
})
|
||||
|
||||
it('adds a Regular on Enter, showing it before the server answers', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.change(input(), { target: { value: ' Chicken enchiladas ' } })
|
||||
// Enter in the input submits its form.
|
||||
fireEvent.submit(input())
|
||||
|
||||
expect(input()).toHaveValue('')
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Chicken enchiladas']))
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
|
||||
// Not removable until the server has given it an id.
|
||||
expect(screen.queryByRole('button', { name: 'Remove Chicken enchiladas' })).not.toBeInTheDocument()
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chicken enchiladas' } })
|
||||
created(server, server.held[0]!, 'd1')
|
||||
expect(await screen.findByRole('button', { name: 'Remove Chicken enchiladas' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a Regular with the "+" button', async () => {
|
||||
const server = await regularsStep()
|
||||
const add = screen.getByRole('button', { name: 'Add Regular' })
|
||||
expect(add).toBeDisabled()
|
||||
fireEvent.change(input(), { target: { value: 'Shrimp scampi' } })
|
||||
fireEvent.click(add)
|
||||
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Shrimp scampi']))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'POST', body: { name: 'Shrimp scampi' } })
|
||||
})
|
||||
|
||||
it('adds a common pick on tap and stops offering it', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili']))
|
||||
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chili' } })
|
||||
})
|
||||
|
||||
it('takes an add back out and says why when the server refuses it', async () => {
|
||||
const server = await regularsStep([dinnerBody('d1', 'Chili')])
|
||||
fireEvent.change(input(), { target: { value: 'chili' } })
|
||||
fireEvent.submit(input())
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili', 'chili']))
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add chili: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
})
|
||||
|
||||
it('says why an add was refused while another add is still unanswered', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Burgers', 'Chili'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
|
||||
// The other add's answer does not hide the refusal.
|
||||
created(server, server.held[1]!, 'd1')
|
||||
await screen.findByRole('button', { name: 'Remove Chili' })
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
})
|
||||
|
||||
it('says why each of two overlapping adds was refused', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
|
||||
'Could not add Burgers: duplicate_dinner message',
|
||||
'Could not add Chili: internal message',
|
||||
]),
|
||||
)
|
||||
expect(regularNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('says why each of two overlapping adds of the same name was refused', async () => {
|
||||
const server = await regularsStep()
|
||||
fireEvent.change(input(), { target: { value: 'Lasagne' } })
|
||||
fireEvent.submit(input())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.change(input(), { target: { value: 'Lasagne' } })
|
||||
fireEvent.submit(input())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Lasagne', 'Lasagne'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Lasagne: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Lasagne'])
|
||||
|
||||
// The second add's refusal shows beside the first instead of replacing it.
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
|
||||
'Could not add Lasagne: duplicate_dinner message',
|
||||
'Could not add Lasagne: internal message',
|
||||
]),
|
||||
)
|
||||
expect(regularNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('removes a Regular at once and archives it on the server', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Pizza night']))
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
|
||||
// Removed, it is a common pick again.
|
||||
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/dinners/d1' })
|
||||
})
|
||||
|
||||
it('puts a removed Regular back in its place when the server refuses', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
||||
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
|
||||
})
|
||||
|
||||
it('puts a refused removal back and says why while another removal is still unanswered', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
dinnerBody('d3', 'Chili'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
||||
expect(regularNames()).toEqual(['Beef tacos', 'Chili'])
|
||||
|
||||
// The other removal's answer does not hide the refusal.
|
||||
server.regulars.splice(1, 1)
|
||||
server.held[1]!.answer(new Response(null, { status: 204 }))
|
||||
await vi.waitFor(() => expect(server.fetchMock.mock.calls.filter(([path]) => String(path).includes('status=regular'))).toHaveLength(2))
|
||||
await vi.waitFor(() => expect(regularNames()).toEqual(['Beef tacos', 'Chili']))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
||||
})
|
||||
|
||||
it('puts two refused removals back in their places without rereading the list', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
dinnerBody('d3', 'Chili'),
|
||||
])
|
||||
server.reads.fail = true
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
expect(regularNames()).toEqual(['Chili'])
|
||||
|
||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
||||
await screen.findByRole('alert')
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await screen.findByText('Could not remove Pizza night: internal message')
|
||||
// The reread after the last answer fails, so the order is the client's own.
|
||||
expect(await screen.findByText(/Your Regulars could not load/)).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: /^Remove / }).map((button) => button.getAttribute('aria-label'))).toEqual([
|
||||
'Remove Beef tacos',
|
||||
'Remove Pizza night',
|
||||
'Remove Chili',
|
||||
])
|
||||
})
|
||||
|
||||
it('enables "Next: notifications" once the server has saved 3 Regulars', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
expect(next()).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
|
||||
// The third chip shows at once, but Next waits for the server.
|
||||
expect(next()).toBeDisabled()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
created(server, server.held[0]!, 'd3')
|
||||
await screen.findByRole('button', { name: 'Remove Burgers' })
|
||||
expect(next()).toBeEnabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
await vi.waitFor(() => expect(next()).toBeDisabled())
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
server.held[1]!.answer(errorResponse(500, 'internal'))
|
||||
await vi.waitFor(() => expect(next()).toBeEnabled())
|
||||
|
||||
fireEvent.click(next())
|
||||
expect(await screen.findByText('Step 3 of 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps "Next: notifications" disabled when the third add is refused', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
|
||||
expect(next()).toBeDisabled()
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
|
||||
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
|
||||
expect(next()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps "Next: notifications" disabled while a fourth add is unanswered', async () => {
|
||||
const server = await regularsStep([
|
||||
dinnerBody('d1', 'Beef tacos'),
|
||||
dinnerBody('d2', 'Pizza night'),
|
||||
dinnerBody('d3', 'Chili'),
|
||||
])
|
||||
expect(next()).toBeEnabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
|
||||
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 4' })
|
||||
expect(next()).toBeDisabled()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
|
||||
await screen.findByRole('alert')
|
||||
expect(next()).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Repeat, ScrollText, Sparkles, type LucideIcon } from 'lucide-react'
|
||||
import { Route, Routes, useNavigate } from 'react-router'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { NotificationsStep } from './NotificationsStep.tsx'
|
||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||
import { RegularsStep } from './RegularsStep.tsx'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
// OnboardingPage is /onboarding and its steps: the welcome, the Regulars
|
||||
// (/onboarding/regulars) and notifications (/onboarding/notifications).
|
||||
export function OnboardingPage() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<WelcomeStep />} />
|
||||
<Route path="regulars" element={<RegularsStep />} />
|
||||
<Route path="notifications" element={<NotificationsStep />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
const terms: { icon: LucideIcon; name: string; meaning: string }[] = [
|
||||
{
|
||||
icon: Repeat,
|
||||
name: 'Regulars',
|
||||
meaning: 'The dinners our kitchen makes on repeat. You add a few next.',
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
name: 'Side quests',
|
||||
meaning: 'New dinners MealQuest suggests from your Regulars. Try it or Pass.',
|
||||
},
|
||||
{
|
||||
icon: ScrollText,
|
||||
name: 'Quest log',
|
||||
meaning: 'The Side quests you said Try it to, waiting for a night on the plan.',
|
||||
},
|
||||
]
|
||||
|
||||
function WelcomeStep() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<OnboardingStep
|
||||
step={1}
|
||||
title="Welcome to MealQuest"
|
||||
action={
|
||||
<Button className={styles.action} onClick={() => void navigate('/onboarding/regulars')}>
|
||||
Let's start
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className={styles.helper}>MealQuest plans the household's dinners around what you already love to cook.</p>
|
||||
<ul className={styles.terms}>
|
||||
{terms.map(({ icon, name, meaning }) => (
|
||||
<li key={name} className={styles.term}>
|
||||
<span className={styles.termIcon}>
|
||||
<Icon icon={icon} size="nav" />
|
||||
</span>
|
||||
<p className={styles.termText}>
|
||||
<span className={styles.termName}>{name}</span>
|
||||
{meaning}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</OnboardingStep>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
export const ONBOARDING_STEPS = 3
|
||||
|
||||
export type OnboardingStepProps = {
|
||||
// 1-based; the progress pills fill up to it.
|
||||
step: number
|
||||
title: string
|
||||
children: ReactNode
|
||||
// The step's primary action, fixed to the bottom of the screen.
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
// OnboardingStep is the frame every onboarding step shares: progress pills,
|
||||
// the "Step N of 3" kicker and the Bagel title.
|
||||
export function OnboardingStep({ step, title, children, action }: OnboardingStepProps) {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.progress} aria-hidden="true">
|
||||
{Array.from({ length: ONBOARDING_STEPS }, (_, i) => (
|
||||
<span key={i} className={[styles.pill, i < step && styles.filled].filter(Boolean).join(' ')} />
|
||||
))}
|
||||
</div>
|
||||
<p className={styles.kicker}>
|
||||
Step {step} of {ONBOARDING_STEPS}
|
||||
</p>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
{action && <div className={styles.footer}>{action}</div>}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useAddRegular, useRegulars, useRemoveRegular, type Regular } from '../dinners/api.ts'
|
||||
import { similarNames } from '../dinners/names.ts'
|
||||
import { useRefusals } from '../dinners/useRefusals.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Chip } from '../ui/Chip.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||
import styles from './Onboarding.module.css'
|
||||
|
||||
// MIN_REGULARS is how many Regulars the server has saved before the
|
||||
// household moves on.
|
||||
export const MIN_REGULARS = 3
|
||||
|
||||
// commonPicks are offered as one-tap Regulars until the household has them,
|
||||
// or a Regular the server would count as the same dinner.
|
||||
export const commonPicks = [
|
||||
'Beef tacos',
|
||||
'Spaghetti bolognese',
|
||||
'Chicken stir-fry',
|
||||
'Pizza night',
|
||||
'Chili',
|
||||
'Pot roast',
|
||||
'Burgers',
|
||||
'Fried rice',
|
||||
]
|
||||
|
||||
// RegularsStep is onboarding step 2, handoff mock 1h: the household adds
|
||||
// the dinners it makes on repeat. Each add and remove shows at once and is
|
||||
// undone if the server refuses it.
|
||||
export function RegularsStep() {
|
||||
const navigate = useNavigate()
|
||||
const regulars = useRegulars()
|
||||
const add = useAddRegular()
|
||||
const remove = useRemoveRegular()
|
||||
const [draft, setDraft] = useState('')
|
||||
const { refusals, track } = useRefusals()
|
||||
const list = regulars.data ?? []
|
||||
const picks = commonPicks.filter((pick) => !list.some((regular) => similarNames(regular.name, pick)))
|
||||
// Next waits for every add to be answered, so a refused add's error shows
|
||||
// here and an add the server takes back never counts towards the three.
|
||||
const adding = list.some((regular) => regular.pending)
|
||||
const saved = list.filter((regular) => !regular.pending).length
|
||||
|
||||
function addRegular(name: string) {
|
||||
track(`add:${name.toLowerCase()}`, `add ${name}`, add.mutateAsync({ name }))
|
||||
}
|
||||
|
||||
function removeRegular(regular: Regular) {
|
||||
track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular))
|
||||
}
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const name = draft.trim()
|
||||
if (name === '') return
|
||||
addRegular(name)
|
||||
setDraft('')
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
step={2}
|
||||
title="What's on your usual rotation?"
|
||||
action={
|
||||
<Button
|
||||
className={styles.action}
|
||||
disabled={adding || saved < MIN_REGULARS}
|
||||
onClick={() => void navigate('/onboarding/notifications')}
|
||||
>
|
||||
Next: notifications
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className={styles.helper}>
|
||||
Add the dinners your household makes on repeat. MealQuest builds suggestions from these.
|
||||
</p>
|
||||
<form className={styles.add} onSubmit={onSubmit}>
|
||||
<TextInput
|
||||
label="Dinner name"
|
||||
labelHidden
|
||||
className={styles.addInput}
|
||||
placeholder="e.g. Chicken enchiladas"
|
||||
autoComplete="off"
|
||||
enterKeyHint="done"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<button type="submit" className={styles.addButton} aria-label="Add Regular" disabled={draft.trim() === ''}>
|
||||
<span className={styles.addFace}>
|
||||
<Icon icon={Plus} size="nav" />
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
{refusals.map((refusal) => (
|
||||
<p key={refusal.key} role="alert" className={styles.problem}>
|
||||
{refusal.text}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<h2 className={styles.groupLabel}>Your regulars · {list.length}</h2>
|
||||
{regulars.isPending && <Skeleton className={styles.status} />}
|
||||
{regulars.isError && (
|
||||
<div className={styles.status}>
|
||||
<ErrorBanner onRetry={() => void regulars.refetch()}>
|
||||
Your Regulars could not load: {regulars.error.message}
|
||||
</ErrorBanner>
|
||||
</div>
|
||||
)}
|
||||
{list.length > 0 && (
|
||||
<ul className={styles.chips}>
|
||||
{list.map((regular: Regular) => (
|
||||
<li key={regular.id}>
|
||||
<Chip
|
||||
label={regular.name}
|
||||
className={regular.pending && styles.pending}
|
||||
aria-busy={regular.pending}
|
||||
onRemove={regular.pending ? undefined : () => removeRegular(regular)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{regulars.isSuccess && picks.length > 0 && (
|
||||
<>
|
||||
<h2 className={styles.groupLabel}>Common picks</h2>
|
||||
<ul className={styles.chips}>
|
||||
{picks.map((pick) => (
|
||||
<li key={pick}>
|
||||
<button type="button" className={styles.pick} aria-label={`Add ${pick}`} onClick={() => addRegular(pick)}>
|
||||
<span aria-hidden="true">+</span>
|
||||
{pick}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</OnboardingStep>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/* Settings · Regular dinners. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.shared {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* The add form: the add row, then the name field a pasted link needs. */
|
||||
.addForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The add field: the input fills the row beside the grape + button. */
|
||||
.add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.add > :first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.add .addInput {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading {
|
||||
height: 320px;
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 6px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
border-top: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.photo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Beat the Tag's own tones whatever order the sheets load in. */
|
||||
.meta .finding {
|
||||
background: var(--color-neutral-200);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.meta .found {
|
||||
background: var(--color-accent-2-200);
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.meta .needs {
|
||||
background: var(--color-accent-100);
|
||||
color: var(--color-accent-800);
|
||||
}
|
||||
|
||||
.filled {
|
||||
color: var(--color-accent);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.problem,
|
||||
.status {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.problem {
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sheetTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
/* Beside a dropdown, a minutes field matches its height. */
|
||||
.columns .paired {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 22px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actions > button {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.candidatesLoading {
|
||||
height: 200px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.candidates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.candidate {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 44px;
|
||||
min-height: 72px;
|
||||
padding: 8px 16px 8px 8px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: 22px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.candidate:hover {
|
||||
background: var(--color-neutral-200);
|
||||
}
|
||||
|
||||
.candidatePhoto {
|
||||
flex: none;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.candidateBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.candidateTitle {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.candidateSite {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* A form locked while its save is pending. It lays out as if it were not
|
||||
there. */
|
||||
.lock {
|
||||
display: contents;
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { GROUNDING_POLL_MS } from '../dinners/api.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { RegularsSettingsPage } from './RegularsSettingsPage.tsx'
|
||||
|
||||
const REGULARS = '/api/v1/dinners?status=regular'
|
||||
|
||||
type Responder = (body: unknown) => Response | Promise<Response>
|
||||
|
||||
// stubServer stands in for the dinners API: GET of the Regulars answers
|
||||
// the current server.regulars, so a test changes what the next read sees;
|
||||
// every other request is answered by responders, keyed "METHOD path" and
|
||||
// called with the request's decoded JSON body. Any other request fails the
|
||||
// test.
|
||||
function stubServer(regulars: DinnerBody[], responders: Record<string, Responder> = {}) {
|
||||
const server = { regulars, fetchMock: undefined as unknown as Mock<typeof fetch> }
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
[`GET ${REGULARS}`]: () => jsonResponse(200, { dinners: server.regulars }),
|
||||
...responders,
|
||||
}
|
||||
server.fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
|
||||
return Promise.resolve(respond(body))
|
||||
})
|
||||
vi.stubGlobal('fetch', server.fetchMock)
|
||||
return server
|
||||
}
|
||||
|
||||
// sent lists the requests other than GETs, as "METHOD path" and body.
|
||||
function sent(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
|
||||
.map(([input, init]) => ({
|
||||
request: `${init?.method} ${String(input)}`,
|
||||
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function reads(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === REGULARS)
|
||||
.length
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the page at /settings/regulars once its Regulars have loaded.
|
||||
async function renderPage() {
|
||||
const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<InDataRouter path="/settings/regulars">
|
||||
<Routes>
|
||||
<Route path="/settings/regulars" element={<RegularsSettingsPage />} />
|
||||
</Routes>
|
||||
</InDataRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 2, name: /^On rotation/ })
|
||||
}
|
||||
|
||||
const TACOS = dinnerBody('d1', 'Beef tacos', {
|
||||
method: 'stovetop',
|
||||
minutes_total: 40,
|
||||
difficulty: 'easy',
|
||||
is_favorite: true,
|
||||
photo_url: '/media/photos/p1-400.jpg',
|
||||
})
|
||||
const CHILI = dinnerBody('d2', 'Chili', { method: 'crockpot', minutes_total: 480, grounding_status: 'pending' })
|
||||
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
|
||||
const ROAST = dinnerBody('d4', 'Pot roast', { method: 'oven', grounding_status: 'manual' })
|
||||
|
||||
function rows() {
|
||||
return within(screen.getByRole('list', { name: 'Regulars' })).queryAllByRole('listitem')
|
||||
}
|
||||
|
||||
function row(name: string) {
|
||||
const item = rows().find((li) => within(li).queryByText(name) !== null)
|
||||
if (item === undefined) throw new Error(`no Regular row named ${name}`)
|
||||
return item
|
||||
}
|
||||
|
||||
function addField() {
|
||||
return screen.getByRole('textbox', { name: 'Add a Regular' })
|
||||
}
|
||||
|
||||
function add(text: string) {
|
||||
fireEvent.change(addField(), { target: { value: text } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Regular' }))
|
||||
}
|
||||
|
||||
function openRow(name: string) {
|
||||
fireEvent.click(within(row(name)).getByRole('button', { name: new RegExp(`^${name}`) }))
|
||||
}
|
||||
|
||||
describe('RegularsSettingsPage', () => {
|
||||
it('lists each Regular with its photo, method and minutes, favorite heart and grounding badge', async () => {
|
||||
stubServer([TACOS, CHILI, PIZZA, ROAST])
|
||||
await renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Regular dinners' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 4' })).toBeInTheDocument()
|
||||
expect(rows()).toHaveLength(4)
|
||||
|
||||
const tacos = within(row('Beef tacos'))
|
||||
expect(tacos.getByRole('img', { name: 'Beef tacos' })).toHaveAttribute('src', '/media/photos/p1-400.jpg')
|
||||
expect(tacos.getByText('Stovetop · 40 min')).toBeInTheDocument()
|
||||
expect(tacos.getByText('Recipe found')).toBeInTheDocument()
|
||||
expect(tacos.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
const chili = within(row('Chili'))
|
||||
expect(chili.getByText('Crock pot · 480 min')).toBeInTheDocument()
|
||||
expect(chili.getByText('Finding recipe…')).toBeInTheDocument()
|
||||
expect(chili.getByRole('button', { name: 'Favorite Chili' })).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
const pizza = within(row('Pizza night'))
|
||||
// No photo yet: the striped placeholder stands in.
|
||||
expect(pizza.getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo')
|
||||
expect(pizza.getByText('Needs a recipe')).toBeInTheDocument()
|
||||
|
||||
expect(within(row('Pot roast')).getByText('Oven')).toBeInTheDocument()
|
||||
expect(within(row('Pot roast')).getByText('Recipe found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps every control at least 44 px', async () => {
|
||||
stubServer([TACOS])
|
||||
await renderPage()
|
||||
expectMinHitArea(screen.getByRole('button', { name: 'Add Regular' }))
|
||||
expectMinHitArea(screen.getByRole('button', { name: 'Favorite Beef tacos' }))
|
||||
expectMinHitArea(within(row('Beef tacos')).getByRole('button', { name: /^Beef tacos/ }))
|
||||
expectMinHitArea(screen.getByRole('button', { name: 'Back to Settings' }))
|
||||
})
|
||||
|
||||
it('says so when there are no Regulars yet', async () => {
|
||||
stubServer([])
|
||||
await renderPage()
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 0' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/No Regulars yet/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports Regulars that could not load, and retries', async () => {
|
||||
const server = stubServer([], { [`GET ${REGULARS}`]: () => errorResponse(500, 'internal') })
|
||||
const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<InDataRouter path="/settings/regulars">
|
||||
<RegularsSettingsPage />
|
||||
</InDataRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Your Regulars could not load: internal message')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
||||
})
|
||||
|
||||
it('adds a Regular by name, showing it at once and finding its recipe', async () => {
|
||||
const server = stubServer([TACOS], {
|
||||
'POST /api/v1/dinners': (body) => {
|
||||
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
||||
server.regulars = [...server.regulars, created]
|
||||
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
add(' Chicken enchiladas ')
|
||||
expect(addField()).toHaveValue('')
|
||||
expect(await screen.findByText('Chicken enchiladas')).toBeInTheDocument()
|
||||
expect(within(row('Chicken enchiladas')).getByText('Finding recipe…')).toBeInTheDocument()
|
||||
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { name: 'Chicken enchiladas' } }])
|
||||
await waitFor(() => expect(row('Chicken enchiladas')).not.toHaveAttribute('aria-busy'))
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a Regular from a pasted recipe link under the name the household gives it', async () => {
|
||||
const link = 'https://example.com/recipes/stew-key-abcdefghijklmno'
|
||||
const server = stubServer([], {
|
||||
'POST /api/v1/dinners': (body) => {
|
||||
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
||||
server.regulars = [created]
|
||||
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.change(addField(), { target: { value: ` ${link} ` } })
|
||||
// A link alone cannot be added: it never names a dinner.
|
||||
expect(screen.getByRole('button', { name: 'Add Regular' })).toBeDisabled()
|
||||
const name = screen.getByRole('textbox', { name: 'Dinner name' })
|
||||
expect(name).toHaveAccessibleDescription(/never names a dinner/)
|
||||
fireEvent.change(name, { target: { value: ' Sunday stew ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Regular' }))
|
||||
|
||||
expect(addField()).toHaveValue('')
|
||||
expect(screen.queryByRole('textbox', { name: 'Dinner name' })).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('Sunday stew')).toBeInTheDocument()
|
||||
expect(sent(server.fetchMock)).toEqual([
|
||||
{ request: 'POST /api/v1/dinners', body: { name: 'Sunday stew', recipe_url: link } },
|
||||
])
|
||||
expect(within(row('Sunday stew')).getByText('Finding recipe…')).toBeInTheDocument()
|
||||
expect(document.body).not.toHaveTextContent(/example\.com|abcdefghijklmno|key/i)
|
||||
})
|
||||
|
||||
it('takes back an add the server refuses and says why', async () => {
|
||||
stubServer([TACOS], { 'POST /api/v1/dinners': () => errorResponse(409, 'duplicate_dinner') })
|
||||
await renderPage()
|
||||
|
||||
add('Beef tacos!')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Beef tacos!: duplicate_dinner message')
|
||||
expect(rows()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('toggles the favorite heart', async () => {
|
||||
const server = stubServer([TACOS], {
|
||||
'POST /api/v1/dinners/d1/favorite': () => {
|
||||
server.regulars = [{ ...TACOS, is_favorite: false }]
|
||||
return jsonResponse(200, server.regulars[0])
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Favorite Beef tacos' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false'),
|
||||
)
|
||||
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners/d1/favorite', body: undefined }])
|
||||
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
||||
expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('edits a Regular: name, method, minutes and difficulty', async () => {
|
||||
const server = stubServer([TACOS], {
|
||||
'PATCH /api/v1/dinners/d1': (body) => {
|
||||
const edited = { ...TACOS, ...(body as object) }
|
||||
server.regulars = [edited]
|
||||
return jsonResponse(200, edited)
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Beef tacos')
|
||||
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
||||
expect(sheet.getByLabelText('Name')).toHaveValue('Beef tacos')
|
||||
expect(sheet.getByLabelText('Method')).toHaveValue('stovetop')
|
||||
expect(sheet.getByLabelText('Minutes')).toHaveValue(40)
|
||||
expect(sheet.getByLabelText('Difficulty')).toHaveValue('easy')
|
||||
expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled()
|
||||
|
||||
fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } })
|
||||
fireEvent.change(sheet.getByLabelText('Method'), { target: { value: 'grill' } })
|
||||
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } })
|
||||
fireEvent.change(sheet.getByLabelText('Difficulty'), { target: { value: 'medium' } })
|
||||
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(sent(server.fetchMock)).toEqual([
|
||||
{
|
||||
request: 'PATCH /api/v1/dinners/d1',
|
||||
body: { name: 'Fish tacos', method: 'grill', minutes_total: 25, difficulty: 'medium' },
|
||||
},
|
||||
])
|
||||
expect(within(row('Fish tacos')).getByText('Grill · 25 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sends only the changed fields, refuses bad minutes and shows a refused edit', async () => {
|
||||
const server = stubServer([TACOS], { 'PATCH /api/v1/dinners/d1': () => errorResponse(400, 'invalid_dinner') })
|
||||
await renderPage()
|
||||
|
||||
openRow('Beef tacos')
|
||||
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
||||
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '0' } })
|
||||
expect(sheet.getByText('Minutes must be a whole number from 1 to 1440.')).toBeInTheDocument()
|
||||
expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled()
|
||||
|
||||
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '45' } })
|
||||
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
||||
expect(await sheet.findByRole('alert')).toHaveTextContent('Not saved: invalid_dinner message')
|
||||
expect(sent(server.fetchMock)).toEqual([{ request: 'PATCH /api/v1/dinners/d1', body: { minutes_total: 45 } }])
|
||||
})
|
||||
|
||||
it('removes a Regular only once confirmed', async () => {
|
||||
const server = stubServer([TACOS, ROAST], {
|
||||
'DELETE /api/v1/dinners/d1': () => {
|
||||
server.regulars = [ROAST]
|
||||
return jsonResponse(200, { ...TACOS, status: 'archived' })
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Beef tacos')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
|
||||
const confirm = within(screen.getByRole('dialog', { name: 'Remove Beef tacos?' }))
|
||||
fireEvent.click(confirm.getByRole('button', { name: 'Keep it' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Edit Beef tacos' })).toBeInTheDocument()
|
||||
expect(sent(server.fetchMock)).toEqual([])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(rows()).toHaveLength(1))
|
||||
expect(within(rows()[0]!).getByText('Pot roast')).toBeInTheDocument()
|
||||
expect(sent(server.fetchMock)).toEqual([{ request: 'DELETE /api/v1/dinners/d1', body: undefined }])
|
||||
})
|
||||
|
||||
it('puts a refused removal back and says why', async () => {
|
||||
stubServer([TACOS], { 'DELETE /api/v1/dinners/d1': () => errorResponse(500, 'internal') })
|
||||
await renderPage()
|
||||
|
||||
openRow('Beef tacos')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
||||
expect(rows()).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('Fix recipe', () => {
|
||||
const CANDIDATES = {
|
||||
candidates: [
|
||||
{
|
||||
candidate_id: 'https://example.com/pizza',
|
||||
title: 'Weeknight pizza',
|
||||
site_name: 'Example Kitchen',
|
||||
photo_url: '/media/photos/p7-400.jpg',
|
||||
minutes: 45,
|
||||
},
|
||||
{ candidate_id: 'https://cook.test/pizza', title: 'Sheet-pan pizza', site_name: 'Cook Test', photo_url: null, minutes: null },
|
||||
{ candidate_id: 'https://food.test/p', title: 'Pizza margherita', site_name: 'Food Test', photo_url: null, minutes: 30 },
|
||||
],
|
||||
}
|
||||
|
||||
function grounded(server: { regulars: DinnerBody[] }, overrides: Partial<DinnerBody>) {
|
||||
const fixed = { ...PIZZA, grounding_error: null, ...overrides }
|
||||
server.regulars = [fixed]
|
||||
return jsonResponse(200, fixed)
|
||||
}
|
||||
|
||||
function fixSheet() {
|
||||
return within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
||||
}
|
||||
|
||||
it('opens from a Needs a recipe row and picks one of up to three matches', async () => {
|
||||
const server = stubServer([PIZZA], {
|
||||
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
||||
'POST /api/v1/dinners/d3/ground': () =>
|
||||
grounded(server, { grounding_status: 'grounded', photo_url: '/media/photos/p7-400.jpg', method: 'oven', minutes_total: 45 }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
const matches = within(await fixSheet().findByRole('list', { name: 'Recipe matches' }))
|
||||
const options = matches.getAllByRole('button')
|
||||
expect(options.map((option) => option.getAttribute('aria-label'))).toEqual([
|
||||
'Use Weeknight pizza from Example Kitchen',
|
||||
'Use Sheet-pan pizza from Cook Test',
|
||||
'Use Pizza margherita from Food Test',
|
||||
])
|
||||
expect(within(options[0]!).getByRole('img', { name: 'Weeknight pizza' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
|
||||
expect(within(options[0]!).getByText('Example Kitchen')).toBeInTheDocument()
|
||||
expectMinHitArea(options[0]!)
|
||||
|
||||
fireEvent.click(options[0]!)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(sent(server.fetchMock)).toEqual([
|
||||
{ request: 'POST /api/v1/dinners/d3/ground', body: { candidate_id: 'https://example.com/pizza' } },
|
||||
])
|
||||
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
|
||||
expect(within(row('Pizza night')).getByText('Oven · 45 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says so when no match was found', async () => {
|
||||
stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }) })
|
||||
await renderPage()
|
||||
openRow('Pizza night')
|
||||
expect(await fixSheet().findByText('No matches were found. Paste a recipe link or type it in.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('grounds a pasted recipe link, and says why a link was refused', async () => {
|
||||
let answers = 0
|
||||
const server = stubServer([PIZZA], {
|
||||
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
||||
'POST /api/v1/dinners/d3/ground': () =>
|
||||
++answers === 1 ? errorResponse(422, 'ungroundable') : grounded(server, { grounding_status: 'grounded' }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
fireEvent.click(fixSheet().getByRole('radio', { name: 'Paste a link' }))
|
||||
fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: ' https://example.com/blog/pizza ' } })
|
||||
fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' }))
|
||||
expect(await fixSheet().findByRole('alert')).toHaveTextContent('Not fixed: ungroundable message')
|
||||
|
||||
fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza-2' } })
|
||||
fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(sent(server.fetchMock)).toEqual([
|
||||
{ request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/blog/pizza' } },
|
||||
{ request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/pizza-2' } },
|
||||
])
|
||||
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves ingredients and steps typed in', async () => {
|
||||
const server = stubServer([PIZZA], {
|
||||
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
||||
'POST /api/v1/dinners/d3/ground': () =>
|
||||
grounded(server, { grounding_status: 'manual', method: 'oven', minutes_total: 35 }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' }))
|
||||
const save = fixSheet().getByRole('button', { name: 'Save recipe' })
|
||||
expect(save).toBeDisabled()
|
||||
fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), {
|
||||
target: { value: '1 pizza dough\n\n 1 cup tomato sauce \n2 cups mozzarella' },
|
||||
})
|
||||
fireEvent.change(fixSheet().getByLabelText('Steps, one per line'), {
|
||||
target: { value: 'Heat the oven to 250°C.\nTop the dough and bake 12 minutes.\n' },
|
||||
})
|
||||
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
|
||||
fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } })
|
||||
fireEvent.click(save)
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(sent(server.fetchMock)).toEqual([
|
||||
{
|
||||
request: 'POST /api/v1/dinners/d3/ground',
|
||||
body: {
|
||||
manual: {
|
||||
ingredients: ['1 pizza dough', '1 cup tomato sauce', '2 cups mozzarella'],
|
||||
steps: ['Heat the oven to 250°C.', 'Top the dough and bake 12 minutes.'],
|
||||
minutes: 35,
|
||||
method: 'oven',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
|
||||
expect(within(row('Pizza night')).getByText('Oven · 35 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves a typed-in recipe only once its method and minutes are set', async () => {
|
||||
const server = stubServer([PIZZA], {
|
||||
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' }))
|
||||
const save = fixSheet().getByRole('button', { name: 'Save recipe' })
|
||||
fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), { target: { value: '1 pizza dough' } })
|
||||
expect(fixSheet().getByText('Choose a method and minutes to save.')).toBeInTheDocument()
|
||||
expect(save).toBeDisabled()
|
||||
|
||||
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
|
||||
expect(save).toBeDisabled()
|
||||
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: '' } })
|
||||
fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } })
|
||||
expect(save).toBeDisabled()
|
||||
|
||||
// Even a submit that skips the button sends nothing.
|
||||
fireEvent.submit(save.closest('form')!)
|
||||
expect(sent(server.fetchMock)).toEqual([])
|
||||
|
||||
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
|
||||
expect(fixSheet().queryByText('Choose a method and minutes to save.')).not.toBeInTheDocument()
|
||||
expect(save).toBeEnabled()
|
||||
})
|
||||
|
||||
it('switches to editing the Regular', async () => {
|
||||
stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES) })
|
||||
await renderPage()
|
||||
openRow('Pizza night')
|
||||
fireEvent.click(fixSheet().getByRole('button', { name: 'Edit or remove Pizza night' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Edit Pizza night' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes grounding status while any Regular is finding its recipe, then stops', async () => {
|
||||
// Only intervals are faked: TanStack Query polls with setInterval, and
|
||||
// Testing Library's waits keep their real timers.
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||
const server = stubServer([TACOS, CHILI])
|
||||
await renderPage()
|
||||
expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument()
|
||||
expect(reads(server.fetchMock)).toBe(1)
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
||||
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
||||
expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument()
|
||||
|
||||
server.regulars = [TACOS, { ...CHILI, grounding_status: 'grounded', photo_url: '/media/photos/p2-400.jpg' }]
|
||||
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
||||
expect(await within(row('Chili')).findByText('Recipe found')).toBeInTheDocument()
|
||||
expect(within(row('Chili')).getByRole('img', { name: 'Chili' })).toHaveAttribute('src', '/media/photos/p2-400.jpg')
|
||||
expect(reads(server.fetchMock)).toBe(3)
|
||||
|
||||
// Nothing is pending any more, so nothing is read again.
|
||||
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
||||
expect(reads(server.fetchMock)).toBe(3)
|
||||
})
|
||||
|
||||
// held answers a request only when release is called, so a test can look
|
||||
// at the page while a save is in flight.
|
||||
function held(answer: () => Response) {
|
||||
let release = () => {}
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
release = () => resolve(answer())
|
||||
})
|
||||
return { respond: () => response, release: () => release() }
|
||||
}
|
||||
|
||||
it('pauses the refresh while an edit is being saved, then resumes', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||
const edited = { ...TACOS, name: 'Fish tacos' }
|
||||
const patch = held(() => jsonResponse(200, edited))
|
||||
const server = stubServer([TACOS, CHILI], { 'PATCH /api/v1/dinners/d1': patch.respond })
|
||||
await renderPage()
|
||||
|
||||
openRow('Beef tacos')
|
||||
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
||||
fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } })
|
||||
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
||||
await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1))
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
||||
expect(reads(server.fetchMock)).toBe(1)
|
||||
|
||||
server.regulars = [edited, CHILI]
|
||||
patch.release()
|
||||
// Settling reads the list once, then the refresh carries on.
|
||||
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
||||
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
||||
await waitFor(() => expect(reads(server.fetchMock)).toBe(3))
|
||||
})
|
||||
|
||||
it('pauses the refresh while a recipe fix is being saved, then resumes', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||
const fixed = { ...PIZZA, grounding_status: 'grounded' as const, grounding_error: null }
|
||||
const ground = held(() => jsonResponse(200, fixed))
|
||||
const server = stubServer([CHILI, PIZZA], {
|
||||
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }),
|
||||
'POST /api/v1/dinners/d3/ground': ground.respond,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
openRow('Pizza night')
|
||||
const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
||||
fireEvent.click(sheet.getByRole('radio', { name: 'Paste a link' }))
|
||||
fireEvent.change(sheet.getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza' } })
|
||||
fireEvent.click(sheet.getByRole('button', { name: 'Use this recipe' }))
|
||||
await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1))
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
||||
expect(reads(server.fetchMock)).toBe(1)
|
||||
|
||||
server.regulars = [CHILI, fixed]
|
||||
ground.release()
|
||||
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
||||
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
||||
await waitFor(() => expect(reads(server.fetchMock)).toBe(3))
|
||||
})
|
||||
|
||||
it('does not poll when no Regular is finding its recipe', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||
const server = stubServer([TACOS, PIZZA])
|
||||
await renderPage()
|
||||
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
||||
expect(reads(server.fetchMock)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,641 @@
|
||||
import { ChevronLeft, Heart, Plus } from 'lucide-react'
|
||||
import { useId, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import {
|
||||
DIFFICULTIES,
|
||||
METHODS,
|
||||
useAddRegular,
|
||||
useEditRegular,
|
||||
useGroundRegular,
|
||||
useRecipeCandidates,
|
||||
useRegulars,
|
||||
useRemoveRegular,
|
||||
useToggleFavorite,
|
||||
type Difficulty,
|
||||
type GroundingStatus,
|
||||
type ManualRecipe,
|
||||
type Method,
|
||||
type NewRegular,
|
||||
type RecipeFix,
|
||||
type Regular,
|
||||
type RegularPatch,
|
||||
} from '../dinners/api.ts'
|
||||
import { useRefusals } from '../dinners/useRefusals.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { ListRow } from '../ui/ListRow.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Select } from '../ui/Select.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import styles from './RegularsSettingsPage.module.css'
|
||||
|
||||
// The most minutes a dinner may take, as the dinners API allows: a day.
|
||||
const MAX_MINUTES = 1440
|
||||
|
||||
export const methodLabels: Record<Method, string> = {
|
||||
stovetop: 'Stovetop',
|
||||
oven: 'Oven',
|
||||
crockpot: 'Crock pot',
|
||||
grill: 'Grill',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||
|
||||
// A pasted link is added as a recipe link under a name the household gives;
|
||||
// anything else is a dinner name.
|
||||
const LINK = /^https?:\/\//i
|
||||
|
||||
// Settings · Regular dinners: the household's Regulars, each with its photo,
|
||||
// method and minutes, favorite heart and whether its recipe was found. The
|
||||
// field at the top adds one by name, or by pasted recipe link with a name
|
||||
// the household gives it; tapping one
|
||||
// edits or removes it, or fixes its recipe when none was found.
|
||||
export function RegularsSettingsPage() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
<div>
|
||||
<p className={styles.kicker}>Settings</p>
|
||||
<h1 className={styles.title}>Regular dinners</h1>
|
||||
</div>
|
||||
</header>
|
||||
<p className={styles.shared}>Changes apply to everyone in the household.</p>
|
||||
<RegularsManager />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Which sheet is open, for which Regular.
|
||||
type Open = { sheet: 'edit' | 'fix'; id: string } | null
|
||||
|
||||
function RegularsManager() {
|
||||
const regulars = useRegulars()
|
||||
const add = useAddRegular()
|
||||
const remove = useRemoveRegular()
|
||||
const favorite = useToggleFavorite()
|
||||
const { refusals, track } = useRefusals()
|
||||
const [draft, setDraft] = useState('')
|
||||
const [linkName, setLinkName] = useState('')
|
||||
const [open, setOpen] = useState<Open>(null)
|
||||
const headingId = useId()
|
||||
const linkHintId = useId()
|
||||
const isLink = LINK.test(draft.trim())
|
||||
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
|
||||
const list = regulars.data ?? []
|
||||
// A Regular removed meanwhile closes its sheet.
|
||||
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!ready) return
|
||||
const text = draft.trim()
|
||||
const body: NewRegular = isLink ? { name: linkName.trim(), recipe_url: text } : { name: text }
|
||||
track(`add:${body.name.toLowerCase()}`, `add ${body.name}`, add.mutateAsync(body))
|
||||
setDraft('')
|
||||
setLinkName('')
|
||||
}
|
||||
|
||||
function onRemove(regular: Regular) {
|
||||
setOpen(null)
|
||||
track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular))
|
||||
}
|
||||
|
||||
function onFavorite(regular: Regular) {
|
||||
track(`favorite:${regular.id}`, `change the favorite heart on ${regular.name}`, favorite.mutateAsync(regular))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className={styles.addForm} onSubmit={onSubmit}>
|
||||
<div className={styles.add}>
|
||||
<TextInput
|
||||
label="Add a Regular"
|
||||
labelHidden
|
||||
className={styles.addInput}
|
||||
placeholder="Dinner name or recipe link"
|
||||
autoComplete="off"
|
||||
enterKeyHint="done"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<IconButton type="submit" label="Add Regular" tone="accent" disabled={!ready}>
|
||||
<Icon icon={Plus} size="nav" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{isLink && (
|
||||
<>
|
||||
<TextInput
|
||||
label="Dinner name"
|
||||
placeholder="What do you call this dinner?"
|
||||
autoComplete="off"
|
||||
enterKeyHint="done"
|
||||
aria-describedby={linkHintId}
|
||||
value={linkName}
|
||||
onChange={(event) => setLinkName(event.target.value)}
|
||||
/>
|
||||
<p id={linkHintId} className={styles.hint}>
|
||||
Name it yourself: a link can carry private details, so it never names a dinner.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
{refusals.map((refusal) => (
|
||||
<p key={refusal.key} role="alert" className={styles.problem}>
|
||||
{refusal.text}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{regulars.isPending && <Skeleton className={styles.loading} />}
|
||||
{regulars.isError && (
|
||||
<ErrorBanner onRetry={() => void regulars.refetch()}>
|
||||
Your Regulars could not load: {regulars.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{regulars.isSuccess && (
|
||||
<section aria-labelledby={headingId} className={styles.section}>
|
||||
<h2 id={headingId} className={styles.groupLabel}>
|
||||
On rotation · {list.length}
|
||||
</h2>
|
||||
{list.length === 0 ? (
|
||||
<p className={styles.hint}>No Regulars yet. Add the dinners your household makes on repeat.</p>
|
||||
) : (
|
||||
<Card className={styles.card}>
|
||||
<ul className={styles.rows} aria-label="Regulars">
|
||||
{list.map((regular) => (
|
||||
<RegularRow
|
||||
key={regular.id}
|
||||
regular={regular}
|
||||
onOpen={() =>
|
||||
setOpen({ sheet: regular.grounding_status === 'failed' ? 'fix' : 'edit', id: regular.id })
|
||||
}
|
||||
onFavorite={() => onFavorite(regular)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{opened && open.sheet === 'edit' && (
|
||||
<EditRegularSheet
|
||||
key={opened.id}
|
||||
regular={opened}
|
||||
onClose={() => setOpen(null)}
|
||||
onRemove={() => onRemove(opened)}
|
||||
/>
|
||||
)}
|
||||
{opened && open.sheet === 'fix' && (
|
||||
<FixRecipeSheet
|
||||
key={opened.id}
|
||||
regular={opened}
|
||||
onClose={() => setOpen(null)}
|
||||
onEdit={() => setOpen({ sheet: 'edit', id: opened.id })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// details is a Regular's "method · minutes" line, or null when neither is
|
||||
// known yet.
|
||||
export function details(regular: Regular): string | null {
|
||||
const parts = [
|
||||
regular.method && methodLabels[regular.method],
|
||||
regular.minutes_total !== null && `${regular.minutes_total} min`,
|
||||
].filter((part): part is string => typeof part === 'string')
|
||||
return parts.length === 0 ? null : parts.join(' · ')
|
||||
}
|
||||
|
||||
const badges: Record<GroundingStatus, { text: string; className: string | undefined }> = {
|
||||
pending: { text: 'Finding recipe…', className: styles.finding },
|
||||
grounded: { text: 'Recipe found', className: styles.found },
|
||||
manual: { text: 'Recipe found', className: styles.found },
|
||||
failed: { text: 'Needs a recipe', className: styles.needs },
|
||||
}
|
||||
|
||||
function GroundingBadge({ status }: { status: GroundingStatus }) {
|
||||
const badge = badges[status]
|
||||
return <Tag className={`${styles.badge} ${badge.className}`}>{badge.text}</Tag>
|
||||
}
|
||||
|
||||
type RegularRowProps = {
|
||||
regular: Regular
|
||||
onOpen: () => void
|
||||
onFavorite: () => void
|
||||
}
|
||||
|
||||
function RegularRow({ regular, onOpen, onFavorite }: RegularRowProps) {
|
||||
const line = details(regular)
|
||||
return (
|
||||
<li className={styles.item} aria-busy={regular.pending}>
|
||||
<ListRow
|
||||
className={regular.pending && styles.pending}
|
||||
leading={<Photo id={regular.photo_id} name={regular.name} radius="14px" sizes="48px" className={styles.photo} />}
|
||||
title={regular.name}
|
||||
meta={
|
||||
<span className={styles.meta}>
|
||||
{line && <span>{line}</span>}
|
||||
<GroundingBadge status={regular.grounding_status} />
|
||||
</span>
|
||||
}
|
||||
onClick={regular.pending ? undefined : onOpen}
|
||||
trailing={
|
||||
<IconButton
|
||||
label={`Favorite ${regular.name}`}
|
||||
aria-pressed={regular.is_favorite}
|
||||
disabled={regular.pending}
|
||||
onClick={onFavorite}
|
||||
>
|
||||
<Icon icon={Heart} size="nav" className={regular.is_favorite ? styles.filled : undefined} />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
type SheetFrameProps = {
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function SheetFrame({ title, onClose, children }: SheetFrameProps) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||
<div className={styles.sheet}>
|
||||
<h2 id={titleId} className={styles.sheetTitle}>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// minutesProblem is why a typed minutes value cannot be saved, or null.
|
||||
function minutesProblem(text: string): string | null {
|
||||
if (text === '') return null
|
||||
const minutes = Number(text)
|
||||
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_MINUTES) {
|
||||
return `Minutes must be a whole number from 1 to ${MAX_MINUTES}.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type Draft = { name: string; method: Method | ''; minutes: string; difficulty: Difficulty | '' }
|
||||
|
||||
function draftOf(regular: Regular): Draft {
|
||||
return {
|
||||
name: regular.name,
|
||||
method: regular.method ?? '',
|
||||
minutes: regular.minutes_total === null ? '' : String(regular.minutes_total),
|
||||
difficulty: regular.difficulty ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// patchOf is the PATCH body for the fields draft changes. A field the
|
||||
// Regular has no value for stays unset until one is chosen.
|
||||
function patchOf(regular: Regular, draft: Draft): RegularPatch {
|
||||
const saved = draftOf(regular)
|
||||
const patch: RegularPatch = {}
|
||||
if (draft.name.trim() !== saved.name) patch.name = draft.name.trim()
|
||||
if (draft.method !== saved.method && draft.method !== '') patch.method = draft.method
|
||||
if (draft.minutes !== saved.minutes && draft.minutes !== '') patch.minutes_total = Number(draft.minutes)
|
||||
if (draft.difficulty !== saved.difficulty && draft.difficulty !== '') patch.difficulty = draft.difficulty
|
||||
return patch
|
||||
}
|
||||
|
||||
type EditRegularSheetProps = {
|
||||
regular: Regular
|
||||
onClose: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
// EditRegularSheet renames a Regular and sets its method, minutes and
|
||||
// difficulty, or removes it from the rotation once confirmed.
|
||||
function EditRegularSheet({ regular, onClose, onRemove }: EditRegularSheetProps) {
|
||||
const [draft, setDraft] = useState(() => draftOf(regular))
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const edit = useEditRegular()
|
||||
const patch = patchOf(regular, draft)
|
||||
const minutesError = minutesProblem(draft.minutes)
|
||||
const nameError = draft.name.trim() === '' ? 'A Regular needs a name.' : null
|
||||
const canSave = Object.keys(patch).length > 0 && minutesError === null && nameError === null
|
||||
|
||||
function change(next: Partial<Draft>) {
|
||||
edit.reset()
|
||||
setDraft((current) => ({ ...current, ...next }))
|
||||
}
|
||||
|
||||
function onSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!canSave) return
|
||||
edit.mutate({ id: regular.id, patch }, { onSuccess: onClose })
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
return (
|
||||
<SheetFrame title={`Remove ${regular.name}?`} onClose={onClose}>
|
||||
<p className={styles.hint}>
|
||||
It leaves your Regulars, so suggestions and plans stop using it. You can add it again later.
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<Button onClick={onRemove}>Remove</Button>
|
||||
<Button variant="outline" onClick={() => setConfirming(false)}>
|
||||
Keep it
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFrame>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SheetFrame title={`Edit ${regular.name}`} onClose={onClose}>
|
||||
<form className={styles.form} onSubmit={onSave}>
|
||||
<fieldset className={styles.lock} disabled={edit.isPending}>
|
||||
<TextInput
|
||||
label="Name"
|
||||
value={draft.name}
|
||||
autoComplete="off"
|
||||
aria-invalid={nameError !== null}
|
||||
onChange={(event) => change({ name: event.target.value })}
|
||||
/>
|
||||
{nameError && <p className={styles.problem}>{nameError}</p>}
|
||||
<div className={styles.columns}>
|
||||
<Select label="Method" value={draft.method} onChange={(event) => change({ method: event.target.value as Method })}>
|
||||
{regular.method === null && (
|
||||
<option value="" disabled>
|
||||
Not set
|
||||
</option>
|
||||
)}
|
||||
{METHODS.map((method) => (
|
||||
<option key={method} value={method}>
|
||||
{methodLabels[method]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<TextInput
|
||||
label="Minutes"
|
||||
className={styles.paired}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_MINUTES}
|
||||
value={draft.minutes}
|
||||
aria-invalid={minutesError !== null}
|
||||
onChange={(event) => change({ minutes: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{minutesError && <p className={styles.problem}>{minutesError}</p>}
|
||||
<Select
|
||||
label="Difficulty"
|
||||
value={draft.difficulty}
|
||||
onChange={(event) => change({ difficulty: event.target.value as Difficulty })}
|
||||
>
|
||||
{regular.difficulty === null && (
|
||||
<option value="" disabled>
|
||||
Not set
|
||||
</option>
|
||||
)}
|
||||
{DIFFICULTIES.map((difficulty) => (
|
||||
<option key={difficulty} value={difficulty}>
|
||||
{difficultyLabels[difficulty]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{edit.isError && (
|
||||
<p role="alert" className={styles.problem}>
|
||||
Not saved: {edit.error.message}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
<Button type="submit" disabled={!canSave}>
|
||||
{edit.isPending ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setConfirming(true)}>
|
||||
Remove from Regulars
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</SheetFrame>
|
||||
)
|
||||
}
|
||||
|
||||
type FixPath = 'pick' | 'link' | 'manual'
|
||||
|
||||
const fixPaths = [
|
||||
{ value: 'pick', label: 'Pick one' },
|
||||
{ value: 'link', label: 'Paste a link' },
|
||||
{ value: 'manual', label: 'Type it in' },
|
||||
] as const
|
||||
|
||||
type FixRecipeSheetProps = {
|
||||
regular: Regular
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
}
|
||||
|
||||
// lines is text split into its non-blank lines, trimmed.
|
||||
function lines(text: string): string[] {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== '')
|
||||
}
|
||||
|
||||
// FixRecipeSheet grounds a Regular whose recipe was not found: in one of
|
||||
// up to three recipe pages found for its name, in a pasted recipe link, or
|
||||
// in ingredients and steps typed in.
|
||||
function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
|
||||
const [path, setPath] = useState<FixPath>('pick')
|
||||
const ground = useGroundRegular()
|
||||
|
||||
function fix(body: RecipeFix) {
|
||||
ground.mutate({ id: regular.id, fix: body }, { onSuccess: onClose })
|
||||
}
|
||||
|
||||
return (
|
||||
<SheetFrame title="Fix recipe" onClose={onClose}>
|
||||
<p className={styles.hint}>
|
||||
We couldn't find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in.
|
||||
</p>
|
||||
<Segmented
|
||||
label="How to fix the recipe"
|
||||
options={fixPaths}
|
||||
value={path}
|
||||
onValueChange={(next) => {
|
||||
ground.reset()
|
||||
setPath(next)
|
||||
}}
|
||||
/>
|
||||
<fieldset className={styles.lock} disabled={ground.isPending}>
|
||||
{path === 'pick' && <PickCandidate id={regular.id} onPick={(candidate_id) => fix({ candidate_id })} />}
|
||||
{path === 'link' && <PasteLink onSubmit={(recipe_url) => fix({ recipe_url })} />}
|
||||
{path === 'manual' && <ManualEntry onSubmit={(manual) => fix({ manual })} />}
|
||||
</fieldset>
|
||||
<p role="status" className={styles.status}>
|
||||
{ground.isPending && 'Finding recipe… this can take a minute.'}
|
||||
</p>
|
||||
{ground.isError && (
|
||||
<p role="alert" className={styles.problem}>
|
||||
Not fixed: {ground.error.message}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" disabled={ground.isPending} onClick={onEdit}>
|
||||
Edit or remove {regular.name}
|
||||
</Button>
|
||||
</SheetFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) {
|
||||
const candidates = useRecipeCandidates(id)
|
||||
if (candidates.isPending) return <Skeleton className={styles.candidatesLoading} />
|
||||
if (candidates.isError) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => void candidates.refetch()}>
|
||||
Recipe matches could not load: {candidates.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
if (candidates.data.length === 0) {
|
||||
return <p className={styles.hint}>No matches were found. Paste a recipe link or type it in.</p>
|
||||
}
|
||||
return (
|
||||
<ul className={styles.candidates} aria-label="Recipe matches">
|
||||
{candidates.data.map((candidate) => (
|
||||
<li key={candidate.candidate_id}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.candidate}
|
||||
aria-label={`Use ${candidate.title} from ${candidate.site_name}`}
|
||||
onClick={() => onPick(candidate.candidate_id)}
|
||||
>
|
||||
<Photo id={candidate.photo_id} name={candidate.title} radius="14px" sizes="56px" className={styles.candidatePhoto} />
|
||||
<span className={styles.candidateBody}>
|
||||
<span className={styles.candidateTitle}>{candidate.title}</span>
|
||||
<span className={styles.candidateSite}>{candidate.site_name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function PasteLink({ onSubmit }: { onSubmit: (recipeUrl: string) => void }) {
|
||||
const [link, setLink] = useState('')
|
||||
const url = link.trim()
|
||||
return (
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (url !== '') onSubmit(url)
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
label="Recipe link"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://"
|
||||
autoComplete="off"
|
||||
value={link}
|
||||
onChange={(event) => setLink(event.target.value)}
|
||||
/>
|
||||
<Button type="submit" disabled={url === ''}>
|
||||
Use this recipe
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ManualEntry({ onSubmit }: { onSubmit: (manual: ManualRecipe) => void }) {
|
||||
const [ingredients, setIngredients] = useState('')
|
||||
const [steps, setSteps] = useState('')
|
||||
const [minutes, setMinutes] = useState('')
|
||||
const [method, setMethod] = useState<Method | ''>('')
|
||||
const ingredientsId = useId()
|
||||
const stepsId = useId()
|
||||
const minutesError = minutesProblem(minutes)
|
||||
const ingredientLines = lines(ingredients)
|
||||
const missing = method === '' || minutes === ''
|
||||
const canSave = ingredientLines.length > 0 && !missing && minutesError === null
|
||||
|
||||
function onSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!canSave) return
|
||||
onSubmit({ ingredients: ingredientLines, steps: lines(steps), minutes: Number(minutes), method })
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={onSave}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor={ingredientsId} className={styles.label}>
|
||||
Ingredients, one per line
|
||||
</label>
|
||||
<textarea
|
||||
id={ingredientsId}
|
||||
className={styles.textarea}
|
||||
rows={5}
|
||||
value={ingredients}
|
||||
onChange={(event) => setIngredients(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor={stepsId} className={styles.label}>
|
||||
Steps, one per line
|
||||
</label>
|
||||
<textarea
|
||||
id={stepsId}
|
||||
className={styles.textarea}
|
||||
rows={5}
|
||||
value={steps}
|
||||
onChange={(event) => setSteps(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.columns}>
|
||||
<Select label="Method" value={method} onChange={(event) => setMethod(event.target.value as Method | '')}>
|
||||
<option value="">Not set</option>
|
||||
{METHODS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{methodLabels[m]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<TextInput
|
||||
label="Minutes"
|
||||
className={styles.paired}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_MINUTES}
|
||||
value={minutes}
|
||||
aria-invalid={minutesError !== null}
|
||||
onChange={(event) => setMinutes(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{minutesError && <p className={styles.problem}>{minutesError}</p>}
|
||||
{ingredientLines.length > 0 && missing && <p className={styles.problem}>Choose a method and minutes to save.</p>}
|
||||
<Button type="submit" disabled={!canSave}>
|
||||
Save recipe
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
+39
-5
@@ -37,13 +37,17 @@ export type HouseholdSettings = {
|
||||
suggestions_time: string
|
||||
chat_autosend_dow: number
|
||||
chat_autosend_time: string
|
||||
// When the household finished onboarding; null until it has, and every
|
||||
// app route sends it to /onboarding. Not editable in settings.
|
||||
onboarding_completed_at: string | null
|
||||
}
|
||||
|
||||
// HouseholdSettingsField is a household setting the settings pages edit.
|
||||
export type HouseholdSettingsField = Exclude<keyof HouseholdSettings, 'onboarding_completed_at'>
|
||||
|
||||
// HouseholdSettingsPatch is the PATCH /api/v1/settings/household body: only
|
||||
// the fields that change.
|
||||
export type HouseholdSettingsPatch = Partial<HouseholdSettings>
|
||||
|
||||
export type HouseholdSettingsField = keyof HouseholdSettings
|
||||
export type HouseholdSettingsPatch = Partial<Pick<HouseholdSettings, HouseholdSettingsField>>
|
||||
|
||||
const householdTimeFields = [
|
||||
'dinner_time',
|
||||
@@ -355,10 +359,13 @@ function parseHouseholdSettings(where: string, body: unknown): HouseholdSettings
|
||||
}
|
||||
days[field] = value
|
||||
}
|
||||
return { timezone: body.timezone, ...times, ...days }
|
||||
if (!isStringOrNull(body.onboarding_completed_at)) {
|
||||
throw new Error(`${where}: onboarding_completed_at ${JSON.stringify(body.onboarding_completed_at)} is not a time or null`)
|
||||
}
|
||||
return { timezone: body.timezone, ...times, ...days, onboarding_completed_at: body.onboarding_completed_at }
|
||||
}
|
||||
|
||||
const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const
|
||||
export const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const
|
||||
|
||||
export function useHouseholdSettings() {
|
||||
const { apiFetch } = useApi()
|
||||
@@ -382,6 +389,21 @@ export function useSaveHouseholdSettings() {
|
||||
})
|
||||
}
|
||||
|
||||
// useCompleteOnboarding records that the household finished onboarding: the
|
||||
// server sets onboarding_completed_at, which ends the first-run redirect.
|
||||
export function useCompleteOnboarding() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async () =>
|
||||
parseHouseholdSettings(
|
||||
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
|
||||
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: { onboarding_completed: true } }),
|
||||
),
|
||||
onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings),
|
||||
})
|
||||
}
|
||||
|
||||
// HouseholdSettingsProblems is a refused household settings save: the reason
|
||||
// for each field the server named, and any reasons that name no field.
|
||||
export type HouseholdSettingsProblems = {
|
||||
@@ -419,6 +441,18 @@ export function householdSettingsProblems(error: Error): HouseholdSettingsProble
|
||||
return problems
|
||||
}
|
||||
|
||||
// needsMoreRegulars reports whether error is the server refusing to finish
|
||||
// onboarding because the household has fewer than three Regulars
|
||||
// (internal/household MinRegulars) — the only refusal of
|
||||
// {"onboarding_completed": true}.
|
||||
export function needsMoreRegulars(error: Error): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.code === 'invalid_settings' &&
|
||||
error.message.startsWith(`${invalidHouseholdPrefix}onboarding_completed: `)
|
||||
)
|
||||
}
|
||||
|
||||
// aiStatus is the AI assistant row's status line: the provider and the
|
||||
// model's display name, or that no model is chosen yet (the seed).
|
||||
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
|
||||
|
||||
@@ -21,7 +21,7 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderAt(path: string, width: number) {
|
||||
async function renderAt(path: string, width: number) {
|
||||
const resize = mockViewport(width)
|
||||
stubFetchByPath(SETTINGS_API)
|
||||
render(
|
||||
@@ -31,6 +31,8 @@ function renderAt(path: string, width: number) {
|
||||
</ApiProviders>
|
||||
</InDataRouter>,
|
||||
)
|
||||
// The routes wait for the household settings (OnboardingGate).
|
||||
await screen.findByRole('navigation', { name: 'Primary' })
|
||||
return resize
|
||||
}
|
||||
|
||||
@@ -49,8 +51,8 @@ function current() {
|
||||
}
|
||||
|
||||
describe('AppShell', () => {
|
||||
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => {
|
||||
renderAt('/', width)
|
||||
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', async (width) => {
|
||||
await renderAt('/', width)
|
||||
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
|
||||
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/',
|
||||
@@ -61,8 +63,8 @@ describe('AppShell', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('shows only the mobile bottom bar below 768 px', () => {
|
||||
renderAt('/', 767)
|
||||
it('shows only the mobile bottom bar below 768 px', async () => {
|
||||
await renderAt('/', 767)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
||||
@@ -80,8 +82,8 @@ describe('AppShell', () => {
|
||||
expect(bar.borderRadius).toBe('var(--radius-pill)')
|
||||
})
|
||||
|
||||
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => {
|
||||
renderAt('/discover', MOBILE)
|
||||
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', async () => {
|
||||
await renderAt('/discover', MOBILE)
|
||||
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
|
||||
expect(pills).toHaveLength(1)
|
||||
const pill = declaredStyle(pills[0]!)
|
||||
@@ -101,24 +103,24 @@ describe('AppShell', () => {
|
||||
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
|
||||
})
|
||||
|
||||
it('makes the full 62 px bar height the mobile tap target', () => {
|
||||
renderAt('/', MOBILE)
|
||||
it('makes the full 62 px bar height the mobile tap target', async () => {
|
||||
await renderAt('/', MOBILE)
|
||||
for (const link of navLinks()) {
|
||||
expect(declaredStyle(link).height).toBe('62px')
|
||||
expectMinHitArea(link)
|
||||
}
|
||||
})
|
||||
|
||||
it('reserves at least 100 px below the mobile scroll area', () => {
|
||||
renderAt('/', MOBILE)
|
||||
it('reserves at least 100 px below the mobile scroll area', async () => {
|
||||
await renderAt('/', MOBILE)
|
||||
const padding = declaredStyle(screen.getByRole('main')).paddingBottom
|
||||
const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding)
|
||||
expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull()
|
||||
expect(Number(reserved![1])).toBeGreaterThanOrEqual(100)
|
||||
})
|
||||
|
||||
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => {
|
||||
renderAt('/plan', width)
|
||||
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', async (width) => {
|
||||
await renderAt('/plan', width)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
expect(nav()).toHaveClass(railStyles.rail!)
|
||||
expect(declaredStyle(nav()).width).toBe('96px')
|
||||
@@ -146,8 +148,8 @@ describe('AppShell', () => {
|
||||
expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!)
|
||||
})
|
||||
|
||||
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => {
|
||||
renderAt('/quick', width)
|
||||
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', async (width) => {
|
||||
await renderAt('/quick', width)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
const sidebar = nav().closest('aside')!
|
||||
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
|
||||
@@ -176,8 +178,8 @@ describe('AppShell', () => {
|
||||
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
|
||||
})
|
||||
|
||||
it('switches layout when the viewport crosses a breakpoint', () => {
|
||||
const resize = renderAt('/', MOBILE)
|
||||
it('switches layout when the viewport crosses a breakpoint', async () => {
|
||||
const resize = await renderAt('/', MOBILE)
|
||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||
resize(TABLET)
|
||||
expect(nav()).toHaveClass(railStyles.rail!)
|
||||
@@ -206,11 +208,10 @@ describe('AppShell', () => {
|
||||
['/settings/household', ['Settings']],
|
||||
['/settings/regulars', ['Settings']],
|
||||
['/settings/calendar', ['Settings']],
|
||||
['/onboarding', []],
|
||||
['/cook-from-pantry', []],
|
||||
['/dinner/abc123', []],
|
||||
])('%s marks %j current', (path, expected) => {
|
||||
renderAt(path, width)
|
||||
])('%s marks %j current', async (path, expected) => {
|
||||
await renderAt(path, width)
|
||||
expect(current()).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,7 +70,7 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderAt(path: string, reducedMotion = false) {
|
||||
async function renderAt(path: string, reducedMotion = false) {
|
||||
mockViewport(MOBILE, { reducedMotion })
|
||||
stubFetchByPath(SETTINGS_API)
|
||||
render(
|
||||
@@ -80,6 +80,8 @@ function renderAt(path: string, reducedMotion = false) {
|
||||
</ApiProviders>
|
||||
</InDataRouter>,
|
||||
)
|
||||
// The routes wait for the household settings (OnboardingGate).
|
||||
await screen.findByRole('navigation', { name: 'Primary' })
|
||||
}
|
||||
|
||||
function nav() {
|
||||
@@ -109,21 +111,21 @@ function animationsOf(element: Element) {
|
||||
}
|
||||
|
||||
describe('BottomBar', () => {
|
||||
it('places the pill on the active item without animating on first render', () => {
|
||||
renderAt('/plan')
|
||||
it('places the pill on the active item without animating on first render', async () => {
|
||||
await renderAt('/plan')
|
||||
expect(pill().style.left).toBe('54px')
|
||||
expect(pill().style.width).toBe('110px')
|
||||
expect(pill()).toBeVisible()
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the pill when no item is active', () => {
|
||||
renderAt('/onboarding')
|
||||
it('hides the pill when no item is active', async () => {
|
||||
await renderAt('/cook-from-pantry')
|
||||
expect(pill()).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => {
|
||||
renderAt('/')
|
||||
it('slides the pill from the old item to the new one over 420 ms on the spring curve', async () => {
|
||||
await renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
|
||||
expect(animationsOf(pill())).toEqual([
|
||||
@@ -139,8 +141,8 @@ describe('BottomBar', () => {
|
||||
expect(pill().style.width).toBe('110px')
|
||||
})
|
||||
|
||||
it('FLIPs every item that moved from its old left to its new one with the same timing', () => {
|
||||
renderAt('/')
|
||||
it('FLIPs every item that moved from its old left to its new one with the same timing', async () => {
|
||||
await renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
|
||||
// Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left
|
||||
@@ -163,8 +165,8 @@ describe('BottomBar', () => {
|
||||
expect(animationsOf(link('Settings'))).toEqual([])
|
||||
})
|
||||
|
||||
it('fades the new label in over 220 ms ease-out after 120 ms', () => {
|
||||
renderAt('/')
|
||||
it('fades the new label in over 220 ms ease-out after 120 ms', async () => {
|
||||
await renderAt('/')
|
||||
fireEvent.click(link('Discover'))
|
||||
|
||||
const label = within(link('Discover')).getByText('Discover')
|
||||
@@ -180,14 +182,14 @@ describe('BottomBar', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not animate when the active item stays the same', () => {
|
||||
renderAt('/plan')
|
||||
it('does not animate when the active item stays the same', async () => {
|
||||
await renderAt('/plan')
|
||||
fireEvent.click(link('Plan'))
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('jumps the pill to the new item with no animation under reduced motion', () => {
|
||||
renderAt('/', true)
|
||||
it('jumps the pill to the new item with no animation under reduced motion', async () => {
|
||||
await renderAt('/', true)
|
||||
fireEvent.click(link('Settings'))
|
||||
|
||||
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
|
||||
@@ -196,8 +198,8 @@ describe('BottomBar', () => {
|
||||
expect(pill().style.width).toBe('110px')
|
||||
})
|
||||
|
||||
it('re-places the pill when the viewport resizes', () => {
|
||||
renderAt('/discover')
|
||||
it('re-places the pill when the viewport resizes', async () => {
|
||||
await renderAt('/discover')
|
||||
expect(pill().style.left).toBe('102px')
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
|
||||
@@ -207,8 +209,8 @@ describe('BottomBar', () => {
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts a second slide from where the first one is on screen, not from its destination', () => {
|
||||
renderAt('/')
|
||||
it('starts a second slide from where the first one is on screen, not from its destination', async () => {
|
||||
await renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
const [firstPill] = runningOn(pill())
|
||||
const firstPlan = runningOn(link('Plan'))[0]!
|
||||
@@ -247,8 +249,8 @@ describe('BottomBar', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('stops a running slide and places the pill on the active item when the viewport resizes', () => {
|
||||
renderAt('/')
|
||||
it('stops a running slide and places the pill on the active item when the viewport resizes', async () => {
|
||||
await renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
|
||||
expect(slide).toHaveLength(4)
|
||||
@@ -269,7 +271,7 @@ describe('BottomBar', () => {
|
||||
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
|
||||
try {
|
||||
renderAt('/discover')
|
||||
await renderAt('/discover')
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
|
||||
fontsReady()
|
||||
await ready
|
||||
@@ -279,8 +281,8 @@ describe('BottomBar', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('crossfades the icon colour with a .2s colour transition', () => {
|
||||
renderAt('/')
|
||||
it('crossfades the icon colour with a .2s colour transition', async () => {
|
||||
await renderAt('/')
|
||||
for (const item of within(nav()).getAllByRole('link')) {
|
||||
expect(declaredStyle(item).transition).toBe('color .2s')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Runs the service worker's activate handler against mocked Cache Storage,
|
||||
// with the Workbox precache and router stubbed out. tsconfig.sw.json checks
|
||||
// this file with the worker's types, like src/sw.ts itself.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('workbox-precaching', () => ({
|
||||
PrecacheFallbackPlugin: class {},
|
||||
matchPrecache: vi.fn(),
|
||||
precacheAndRoute: vi.fn(),
|
||||
}))
|
||||
vi.mock('workbox-routing', () => ({ registerRoute: vi.fn() }))
|
||||
|
||||
describe('service worker activation', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('deletes the v1 API cache and keeps the precache, photo and font caches', async () => {
|
||||
// A household on the v1 worker: its API cache held dinners with the
|
||||
// pasted recipe_url; its photos and fonts are not in the precache.
|
||||
const names = [
|
||||
'mealquest-precache-v1',
|
||||
'mealquest-api-v1',
|
||||
'mealquest-media-v1',
|
||||
'mealquest-fonts-v1',
|
||||
'other-app-cache',
|
||||
]
|
||||
const cacheStorage = {
|
||||
keys: vi.fn(async () => names),
|
||||
delete: vi.fn(async () => true),
|
||||
}
|
||||
vi.stubGlobal('caches', cacheStorage)
|
||||
vi.stubGlobal('__WB_MANIFEST', [])
|
||||
await import('./sw')
|
||||
|
||||
const pending: Promise<unknown>[] = []
|
||||
const activate = Object.assign(new Event('activate'), {
|
||||
waitUntil: (promise: Promise<unknown>) => pending.push(promise),
|
||||
})
|
||||
self.dispatchEvent(activate)
|
||||
await Promise.all(pending)
|
||||
|
||||
expect(pending).toHaveLength(1)
|
||||
expect(cacheStorage.delete).toHaveBeenCalledWith('mealquest-api-v1')
|
||||
expect(cacheStorage.delete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -18,7 +18,7 @@ import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
||||
import { createApiStrategy } from './sw/apiStrategy'
|
||||
import {
|
||||
cacheNames,
|
||||
cacheVersion,
|
||||
cacheVersions,
|
||||
routeFor,
|
||||
staleCaches,
|
||||
type Route,
|
||||
@@ -29,7 +29,7 @@ declare const self: ServiceWorkerGlobalScope & {
|
||||
}
|
||||
|
||||
// The precache becomes mealquest-precache-v1.
|
||||
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
|
||||
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersions.precache })
|
||||
|
||||
const day = 24 * 60 * 60
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
|
||||
import { cacheFallbackHeader, cacheNames, cacheVersions, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
@@ -146,6 +146,18 @@ describe('cache names', () => {
|
||||
it('all start with mealquest-', () => {
|
||||
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
|
||||
})
|
||||
|
||||
it('retire only the v1 API cache', () => {
|
||||
// v1 API responses held dinners with the pasted recipe_url, which can
|
||||
// carry a personal token; offline, the worker would still serve them.
|
||||
// The precache, photos and fonts keep their v1 caches.
|
||||
expect(cacheNames).toEqual({
|
||||
api: 'mealquest-api-v2',
|
||||
media: 'mealquest-media-v1',
|
||||
fonts: 'mealquest-fonts-v1',
|
||||
})
|
||||
expect(cacheVersions.precache).toBe('v1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('staleCaches', () => {
|
||||
|
||||
+14
-5
@@ -24,14 +24,23 @@ export interface RouteRequest {
|
||||
mode: RequestMode
|
||||
}
|
||||
|
||||
// Bumping the version renames every cache; activate then deletes the old ones.
|
||||
export const cacheVersion = 'v1'
|
||||
// Each cache carries its own version. Bumping one renames only that cache;
|
||||
// activate then deletes its old name and keeps every other cache, so saved
|
||||
// photos and fonts stay available offline.
|
||||
export const cacheVersions = {
|
||||
precache: 'v1',
|
||||
// v2 retires the v1 API cache, whose dinner responses still carried the
|
||||
// pasted recipe_url.
|
||||
api: 'v2',
|
||||
media: 'v1',
|
||||
fonts: 'v1',
|
||||
} as const
|
||||
export const cachePrefix = 'mealquest-'
|
||||
|
||||
export const cacheNames = {
|
||||
api: `${cachePrefix}api-${cacheVersion}`,
|
||||
media: `${cachePrefix}media-${cacheVersion}`,
|
||||
fonts: `${cachePrefix}fonts-${cacheVersion}`,
|
||||
api: `${cachePrefix}api-${cacheVersions.api}`,
|
||||
media: `${cachePrefix}media-${cacheVersions.media}`,
|
||||
fonts: `${cachePrefix}fonts-${cacheVersions.fonts}`,
|
||||
} as const
|
||||
|
||||
export const networkTimeoutSeconds = 3
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// source: they pin the parts routes.ts cannot express — the API cache's
|
||||
// strategy, the font strategy's expiration, the font route's place ahead of
|
||||
// the precache route and network-only requests left to the browser.
|
||||
// src/sw.test.ts runs the worker's activation against mocked Cache Storage.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import source from '../sw.ts?raw'
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export const AI_SETTINGS = {
|
||||
export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
|
||||
|
||||
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
|
||||
// times in New York.
|
||||
// times in New York, onboarding finished.
|
||||
export const HOUSEHOLD_SETTINGS = {
|
||||
timezone: 'America/New_York',
|
||||
dinner_time: '17:30',
|
||||
@@ -33,7 +33,7 @@ export const HOUSEHOLD_SETTINGS = {
|
||||
suggestions_time: '08:00',
|
||||
chat_autosend_dow: 6,
|
||||
chat_autosend_time: '08:00',
|
||||
onboarding_completed_at: null,
|
||||
onboarding_completed_at: '2026-10-01T15:30:00.000Z',
|
||||
updates_seen_at: null,
|
||||
updated_by: null,
|
||||
created_at: '2026-10-01T15:00:00.000Z',
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// DinnerBody is a dinner as the dinners API sends it.
|
||||
export type DinnerBody = {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
origin: string
|
||||
method: string | null
|
||||
minutes_total: number | null
|
||||
difficulty: string | null
|
||||
cuisine: string | null
|
||||
is_favorite: boolean
|
||||
recipe_id: string | null
|
||||
photo_url: string | null
|
||||
grounding_status: string
|
||||
grounding_error: string | null
|
||||
updated_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// dinnerBody is a Regular as the server sends it: grounded with no photo,
|
||||
// method or minutes unless overrides set them.
|
||||
export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBody> = {}): DinnerBody {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
status: 'regular',
|
||||
origin: 'regular',
|
||||
method: null,
|
||||
minutes_total: null,
|
||||
difficulty: null,
|
||||
cuisine: null,
|
||||
is_favorite: false,
|
||||
recipe_id: null,
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
updated_by: null,
|
||||
created_at: '2026-10-01T15:00:00.000Z',
|
||||
updated_at: '2026-10-01T15:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -19,5 +19,5 @@
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "manifest.json"],
|
||||
"exclude": ["src/sw.ts"]
|
||||
"exclude": ["src/sw.ts", "src/sw.test.ts"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"lib": ["ES2022", "WebWorker"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/sw.ts"],
|
||||
"include": ["src/sw.ts", "src/sw.test.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user