Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #174.
This commit is contained in:
+22
-8
@@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, HOME, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
import { dinnerBody } from './test/dinners.ts'
|
||||
import { defaultWeek } from './plan/week.ts'
|
||||
@@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
|
||||
// The routes that render a built screen rather than a placeholder page.
|
||||
const SCREENS = {
|
||||
'/': true,
|
||||
'/onboarding': true,
|
||||
'/plan': true,
|
||||
'/plan/shopping': true,
|
||||
@@ -85,6 +86,16 @@ describe('App routes', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('/ renders the Home screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
|
||||
renderAt('/')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings renders the settings home inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
@@ -262,12 +273,13 @@ describe('App bootstrap', () => {
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS), jsonResponse(200, HOME))
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -282,9 +294,9 @@ describe('App offline bootstrap', () => {
|
||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||
// member, then the app is closed.
|
||||
async function visitOnline() {
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
|
||||
const { unmount } = renderAt('/')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
|
||||
@@ -295,14 +307,16 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
|
||||
// A test that emits an offline event leaves TanStack Query's online
|
||||
// manager, shared by every test, offline until an online event.
|
||||
// manager, shared by every test, offline until an online event. The
|
||||
// manager listens for it only while a query client is mounted, so the
|
||||
// event goes out before cleanup unmounts the app.
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
window.dispatchEvent(new Event('online'))
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/', 'Home'],
|
||||
['/', "Tonight's quest"],
|
||||
['/settings/notifications', 'Notifications'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { HomePage } from './home/HomePage.tsx'
|
||||
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
||||
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
@@ -45,6 +46,7 @@ export const routes = [
|
||||
|
||||
// The screens built so far; every other route shows its placeholder page.
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/': <HomePage />,
|
||||
'/plan': <PlanPage />,
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/* Home, handoff mock 1g. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* The bell's 8 px grape unread dot sits on the 40 px face, which is
|
||||
centred in the IconButton's 44 px hit box. */
|
||||
.bell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bell .bellIcon {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 11px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 32px;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.cardFoot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 2px 18px 16px;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* The dark "Recipe" pill: the mock's 38 px pill at the 44 px tap minimum. */
|
||||
.recipe {
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
box-sizing: border-box;
|
||||
height: 104px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border-radius: 26px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.quick {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.pantry {
|
||||
background: var(--color-accent-2-300);
|
||||
color: var(--color-accent-2-900);
|
||||
}
|
||||
|
||||
.tileLabel {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 16px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.statuses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
box-sizing: border-box;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.status .statusIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.statusText {
|
||||
flex: 1;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tile:focus-visible,
|
||||
.status:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skeletonDate {
|
||||
width: 96px;
|
||||
height: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
height: 238px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.skeletonStatus {
|
||||
height: 44px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, jsonResponse, stubFetch, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { HOME_PATH } from './api.ts'
|
||||
import { HomePage } from './HomePage.tsx'
|
||||
|
||||
// NOTHING_YET is the home of a household with no plan, no Side quests, no
|
||||
// Supply run and nothing unread.
|
||||
const NOTHING_YET = {
|
||||
...HOME,
|
||||
tonight: null,
|
||||
sideQuestCount: 0,
|
||||
shoppingList: { weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4', ready: false, sentToChatAt: null },
|
||||
recentUpdates: { unread: false },
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Where shows the route a link went to.
|
||||
function Where() {
|
||||
const location = useLocation()
|
||||
return <p data-testid="where">{location.pathname + location.search}</p>
|
||||
}
|
||||
|
||||
function renderHome(body: unknown) {
|
||||
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body) })
|
||||
return renderRouter()
|
||||
}
|
||||
|
||||
function renderRouter() {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/', element: <HomePage /> },
|
||||
{ path: '*', element: <Where /> },
|
||||
],
|
||||
{ initialEntries: ['/'] },
|
||||
)
|
||||
render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ApiProviders>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('HomePage', () => {
|
||||
it('renders mock 1g from GET /api/v1/home', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
|
||||
const tonight = screen.getByRole('region', { name: 'Tonight' })
|
||||
expect(within(tonight).getByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(within(tonight).getByText('Regular · 40 min')).toBeInTheDocument()
|
||||
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p1-400.jpg')
|
||||
expect(within(tonight).getByRole('link', { name: 'Recipe' })).toHaveAttribute('href', '/dinner/d1')
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toHaveAttribute('href', '/quick')
|
||||
expect(screen.getByRole('link', { name: 'Cook from what I have' })).toHaveAttribute('href', '/cook-from-pantry')
|
||||
expect(screen.getByRole('link', { name: '10 new side quests to swipe' })).toHaveAttribute('href', '/discover')
|
||||
expect(screen.getByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toHaveAttribute(
|
||||
'href',
|
||||
'/plan/shopping?week=2026-10-05',
|
||||
)
|
||||
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the grape unread dot on the bell when there are unread updates', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
const bell = await screen.findByRole('button', { name: 'Recent updates, unread' })
|
||||
const dot = within(bell).getByTestId('unread-dot')
|
||||
expect(declaredStyle(dot).background).toBe('var(--color-accent)')
|
||||
expect(declaredStyle(dot).width).toBe('8px')
|
||||
expectMinHitArea(bell)
|
||||
})
|
||||
|
||||
it('shows the bell without a dot when every update is seen', async () => {
|
||||
renderHome(NOTHING_YET)
|
||||
|
||||
const bell = await screen.findByRole('button', { name: 'Recent updates' })
|
||||
expect(within(bell).queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels a Side quest tonight "Side quest"', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'side_quest', minutes_total: 30 } })
|
||||
expect(await screen.findByText('Side quest · 30 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only the origin when tonight has no minutes', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: null, photo: null } })
|
||||
const tonight = await screen.findByRole('region', { name: 'Tonight' })
|
||||
expect(within(tonight).getByText('Regular')).toBeInTheDocument()
|
||||
// No photo: the striped placeholder, named for the dinner.
|
||||
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('offers to plan the week when tonight has no plan, and hides the rows that do not apply', async () => {
|
||||
renderHome(NOTHING_YET)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
// The tiles stay.
|
||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Plan this week' }))
|
||||
expect(await screen.findByTestId('where')).toHaveTextContent('/plan')
|
||||
})
|
||||
|
||||
it('hides the Side quests row at zero and keeps a ready Supply run', async () => {
|
||||
renderHome({ ...HOME, sideQuestCount: 0 })
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Supply run row when no list is ready', async () => {
|
||||
renderHome({ ...HOME, shoppingList: NOTHING_YET.shoppingList })
|
||||
|
||||
expect(await screen.findByRole('link', { name: '10 new side quests to swipe' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts a single Side quest in the singular', async () => {
|
||||
renderHome({ ...HOME, sideQuestCount: 1 })
|
||||
expect(await screen.findByRole('link', { name: '1 new side quest to swipe' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a week spanning two months in full', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4' } })
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list ready · Sep 28–Oct 4' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Recipe', '/dinner/d1'],
|
||||
['Quick dinner', '/quick'],
|
||||
['Cook from what I have', '/cook-from-pantry'],
|
||||
['10 new side quests to swipe', '/discover'],
|
||||
['Shopping list ready · Oct 5–11', '/plan/shopping?week=2026-10-05'],
|
||||
])('%s goes to %s', async (name, to) => {
|
||||
renderHome(HOME)
|
||||
|
||||
fireEvent.click(await screen.findByRole('link', { name }))
|
||||
expect(await screen.findByTestId('where')).toHaveTextContent(to)
|
||||
})
|
||||
|
||||
it('styles the card, tiles and rows as mock 1g with tap targets of at least 44 px', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
const tonight = await screen.findByRole('region', { name: 'Tonight' })
|
||||
expect(declaredStyle(tonight).borderRadius).toBe('32px')
|
||||
expect(declaredStyle(within(tonight).getByRole('img')).height).toBe('150px')
|
||||
expect(declaredStyle(within(tonight).getByRole('heading')).fontSize).toBe('22px')
|
||||
|
||||
const quick = screen.getByRole('link', { name: 'Quick dinner' })
|
||||
expect(declaredStyle(quick).height).toBe('104px')
|
||||
expect(declaredStyle(quick).background).toBe('var(--color-accent)')
|
||||
expect(declaredStyle(quick).color).toBe('var(--on-accent)')
|
||||
const pantry = screen.getByRole('link', { name: 'Cook from what I have' })
|
||||
expect(declaredStyle(pantry).height).toBe('104px')
|
||||
expect(declaredStyle(pantry).background).toBe('var(--color-accent-2-300)')
|
||||
|
||||
expect(declaredStyle(screen.getByText('Friday, Oct 2')).fontSize).toBe('12px')
|
||||
|
||||
expectMinHitArea(within(tonight).getByRole('link', { name: 'Recipe' }))
|
||||
// The tiles are 104 px tall (above); the status rows fill the width at 44 px or more.
|
||||
for (const row of within(screen.getByRole('list', { name: 'Status' })).getAllByRole('link')) {
|
||||
expect(declaredStyle(row).minHeight).toBe('44px')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows skeletons while the home loads', async () => {
|
||||
let answer!: (response: Response) => void
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>(() => new Promise((resolve) => (answer = resolve))),
|
||||
)
|
||||
renderRouter()
|
||||
|
||||
expect(screen.getByTestId('skeleton-tonight')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('skeleton-date')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('skeleton-status')).toHaveLength(2)
|
||||
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
|
||||
answer(jsonResponse(200, HOME))
|
||||
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-tonight')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a failure with Retry, and no made-up content', async () => {
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, HOME))
|
||||
renderRouter()
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message")
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a home it cannot read rather than guessing', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'leftovers' } })
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('origin "leftovers" is not one of regular, side_quest')
|
||||
})
|
||||
|
||||
it.each([0, -1, 2.5])('reports tonight with minutes_total %s rather than showing it', async (minutes) => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: minutes } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
`tonight's minutes_total ${minutes} is not a positive whole number`,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/min$/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports tonight without a minutes_total rather than showing it as unknown', async () => {
|
||||
const { minutes_total: _, ...tonight } = HOME.tonight
|
||||
renderHome({ ...HOME, tonight })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('tonight has no minutes_total')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing', undefined],
|
||||
['an empty', ''],
|
||||
])('reports a ready Supply run with %s label rather than showing it', async (_, label) => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('the ready shoppingList has no label')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a ready Supply run whose label is not its week rather than showing it', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label: 'Week 40 · Sep 28 – Oct 4' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'shoppingList label "Week 40 · Sep 28 – Oct 4" is not the week of 2026-10-05',
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing', undefined],
|
||||
['an empty', ''],
|
||||
])('reports an unready Supply run with %s label rather than showing the home', async (_, label) => {
|
||||
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('the unready shoppingList has no label')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports an unready Supply run whose label is not its week rather than showing the home', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label: 'Week 41 · Oct 5 – 11' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'shoppingList label "Week 41 · Oct 5 – 11" is not the week of 2026-09-28',
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a Supply run whose weekStart is not a date rather than showing it', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: 'Oct 5' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('not a YYYY-MM-DD date')
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Bell, ChevronRight, Compass, Search, ShoppingCart, Zap, type LucideIcon } from 'lucide-react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { duration } from '../plan/PlanPage.tsx'
|
||||
import { supplyRunScreen } from '../shopping/api.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { useHome, type Home, type Origin, type Tonight } from './api.ts'
|
||||
import styles from './HomePage.module.css'
|
||||
|
||||
const ORIGIN_LABEL: Record<Origin, string> = { regular: 'Regular', side_quest: 'Side quest' }
|
||||
|
||||
// meta is the Tonight card's "Regular · 40 min" line, the time only when
|
||||
// the recipe says it.
|
||||
function meta({ origin, minutes_total }: Tonight): string {
|
||||
return minutes_total === null ? ORIGIN_LABEL[origin] : `${ORIGIN_LABEL[origin]} · ${duration(minutes_total)}`
|
||||
}
|
||||
|
||||
// TonightCard is the dinner planned for today: its washed photo, name and
|
||||
// meta line, and the dark Recipe pill to its recipe.
|
||||
function TonightCard({ tonight }: { tonight: Tonight }) {
|
||||
return (
|
||||
<section aria-label="Tonight" className={styles.card}>
|
||||
<Photo id={tonight.photo_id} name={tonight.name} inset="10px" sizes="400px" className={styles.photo} />
|
||||
<div className={styles.cardFoot}>
|
||||
<div className={styles.cardText}>
|
||||
<h2 className={styles.name}>{tonight.name}</h2>
|
||||
<span className={styles.meta}>{meta(tonight)}</span>
|
||||
</div>
|
||||
<Link to={`/dinner/${encodeURIComponent(tonight.id)}`} className={[buttonStyles.button, buttonStyles.dark, styles.recipe].join(' ')}>
|
||||
Recipe
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// NoPlan stands in for the Tonight card when today has no plan.
|
||||
function NoPlan() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<EmptyState title="No quest planned tonight" action={{ label: 'Plan this week', onClick: () => void navigate('/plan') }}>
|
||||
Draft this week's dinners and tonight's quest shows up here.
|
||||
</EmptyState>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// StatusRow is one neutral row under the tiles: a grape-700 icon, its line
|
||||
// and a chevron, linking to the screen it is about.
|
||||
function StatusRow({ to, icon, children }: { to: string; icon: LucideIcon; children: string }) {
|
||||
return (
|
||||
<li>
|
||||
<Link to={to} className={styles.status}>
|
||||
<Icon icon={icon} className={styles.statusIcon} />
|
||||
<span className={styles.statusText}>{children}</span>
|
||||
<Icon icon={ChevronRight} />
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// StatusRows are the rows that apply: Side quests waiting in the deck, and
|
||||
// a Supply run ready. With neither, there are no rows.
|
||||
function StatusRows({ home }: { home: Home }) {
|
||||
const count = home.sideQuestCount
|
||||
const list = home.shoppingList
|
||||
if (count === 0 && !list.ready) return null
|
||||
return (
|
||||
<ul aria-label="Status" className={styles.statuses}>
|
||||
{count > 0 && (
|
||||
<StatusRow to="/discover" icon={Compass}>
|
||||
{`${count} new side ${count === 1 ? 'quest' : 'quests'} to swipe`}
|
||||
</StatusRow>
|
||||
)}
|
||||
{list.ready && (
|
||||
<StatusRow to={supplyRunScreen(list.weekStart)} icon={ShoppingCart}>
|
||||
{`Shopping list ready · ${list.dates}`}
|
||||
</StatusRow>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// Tiles are the two shortcuts, Quick dinner and Cook from what I have.
|
||||
function Tiles() {
|
||||
return (
|
||||
<div className={styles.tiles}>
|
||||
<Link to="/quick" className={[styles.tile, styles.quick].join(' ')}>
|
||||
<Icon icon={Zap} size="nav" />
|
||||
<span className={styles.tileLabel}>Quick dinner</span>
|
||||
</Link>
|
||||
<Link to="/cook-from-pantry" className={[styles.tile, styles.pantry].join(' ')}>
|
||||
<Icon icon={Search} size="nav" />
|
||||
<span className={styles.tileLabel}>Cook from what I have</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// HomePage is mock 1g, Home: today's date over "Tonight's quest" and the
|
||||
// Recent updates bell, the Tonight card (or the way to plan the week), the
|
||||
// Quick dinner and Cook from what I have tiles, and the status rows, all
|
||||
// from GET /api/v1/home.
|
||||
//
|
||||
// The bell only shows whether there are unread updates; the Recent updates
|
||||
// sheet it opens is still to come.
|
||||
export function HomePage() {
|
||||
const home = useHome()
|
||||
const data = home.data
|
||||
return (
|
||||
<div className={styles.page} aria-busy={home.isPending}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
{data !== undefined ? (
|
||||
<p className={styles.date}>{data.dateLabel}</p>
|
||||
) : (
|
||||
home.isPending && <Skeleton data-testid="skeleton-date" className={styles.skeletonDate} />
|
||||
)}
|
||||
<h1 className={styles.title}>Tonight's quest</h1>
|
||||
</div>
|
||||
<IconButton label={data?.unread ? 'Recent updates, unread' : 'Recent updates'} className={styles.bell}>
|
||||
<Icon icon={Bell} className={styles.bellIcon} />
|
||||
{data?.unread && <span data-testid="unread-dot" className={styles.dot} />}
|
||||
</IconButton>
|
||||
</header>
|
||||
{home.isPending && <Skeleton data-testid="skeleton-tonight" className={styles.skeletonCard} />}
|
||||
{home.isError && (
|
||||
<ErrorBanner onRetry={() => void home.refetch()}>Couldn't load tonight's quest. {home.error.message}</ErrorBanner>
|
||||
)}
|
||||
{data !== undefined && (data.tonight === null ? <NoPlan /> : <TonightCard tonight={data.tonight} />)}
|
||||
<Tiles />
|
||||
{home.isPending && (
|
||||
<div className={styles.statuses} aria-hidden="true">
|
||||
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
|
||||
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
|
||||
</div>
|
||||
)}
|
||||
{data !== undefined && <StatusRows home={data} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { oneOf, photoId } from '../dinners/api.ts'
|
||||
import { weekLabel } from '../plan/week.ts'
|
||||
|
||||
export const HOME_PATH = '/api/v1/home'
|
||||
export const HOME_KEY = ['home'] as const
|
||||
|
||||
export const ORIGINS = ['regular', 'side_quest'] as const
|
||||
export type Origin = (typeof ORIGINS)[number]
|
||||
|
||||
// Tonight is the dinner planned for today: photo_id is its stored photo's
|
||||
// id, from photo, and minutes_total its minutes from prep to table, a
|
||||
// positive whole number, or null when the recipe does not say.
|
||||
export type Tonight = { id: string; name: string; photo_id: string | undefined; origin: Origin; minutes_total: number | null }
|
||||
|
||||
// ShoppingListStatus is the Supply run Home points at: next week's once it
|
||||
// has one, otherwise this week's, ready when that week has a list.
|
||||
// weekStart is the week's Monday, YYYY-MM-DD, and dates the week as the
|
||||
// status row shows it, "Oct 5–11" (the API's label is the long "Week 41 ·
|
||||
// Oct 5 – 11").
|
||||
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
|
||||
|
||||
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
|
||||
// Oct 2"; tonight is null when today has no plan; sideQuestCount is how
|
||||
// many Side quests wait in the deck; unread says the Recent updates hold
|
||||
// something not yet seen.
|
||||
export type Home = {
|
||||
dateLabel: string
|
||||
tonight: Tonight | null
|
||||
sideQuestCount: number
|
||||
shoppingList: ShoppingListStatus
|
||||
unread: boolean
|
||||
}
|
||||
|
||||
function parseTonight(where: string, body: unknown): Tonight | null {
|
||||
if (body === null) return null
|
||||
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||
throw new Error(`${where}: tonight is not a dinner`)
|
||||
}
|
||||
if (!('minutes_total' in body)) throw new Error(`${where}: tonight has no minutes_total`)
|
||||
const minutes = body.minutes_total
|
||||
if (minutes !== null && (!Number.isInteger(minutes) || (minutes as number) <= 0)) {
|
||||
throw new Error(`${where}: tonight's minutes_total ${String(minutes)} is not a positive whole number`)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
photo_id: photoId(where, body.photo),
|
||||
origin: oneOf(where, 'origin', ORIGINS, body.origin),
|
||||
minutes_total: minutes as number | null,
|
||||
}
|
||||
}
|
||||
|
||||
// parseShoppingList checks the shoppingList of a GET /api/v1/home body. The
|
||||
// list, ready or not, must carry the API's label for its week, which must be
|
||||
// the label weekStart has, since the status row shows dates from weekStart.
|
||||
function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
|
||||
if (!isRecord(list) || typeof list.weekStart !== 'string' || typeof list.ready !== 'boolean') {
|
||||
throw new Error(`${where}: shoppingList is not a week's Supply run status`)
|
||||
}
|
||||
const week = weekLabel(list.weekStart)
|
||||
if (typeof list.label !== 'string' || list.label === '') {
|
||||
throw new Error(`${where}: the ${list.ready ? 'ready' : 'unready'} shoppingList has no label`)
|
||||
}
|
||||
if (list.label !== `${week.week} · ${week.dates}`) {
|
||||
throw new Error(`${where}: shoppingList label "${list.label}" is not the week of ${list.weekStart}`)
|
||||
}
|
||||
return { weekStart: list.weekStart, dates: week.dates.replace(' – ', '–'), ready: list.ready }
|
||||
}
|
||||
|
||||
// parseHome checks a GET /api/v1/home body.
|
||||
export function parseHome(where: string, body: unknown): Home {
|
||||
if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`)
|
||||
if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) {
|
||||
throw new Error(`${where}: sideQuestCount is not a count`)
|
||||
}
|
||||
const updates = body.recentUpdates
|
||||
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
|
||||
return {
|
||||
dateLabel: body.dateLabel,
|
||||
tonight: parseTonight(where, body.tonight),
|
||||
sideQuestCount: body.sideQuestCount as number,
|
||||
shoppingList: parseShoppingList(where, body.shoppingList),
|
||||
unread: updates.unread,
|
||||
}
|
||||
}
|
||||
|
||||
// useHome is the Home screen's summary (GET /api/v1/home).
|
||||
export function useHome() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: HOME_KEY,
|
||||
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,7 @@ 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 { ApiProviders, HOME, 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'
|
||||
@@ -58,6 +58,7 @@ function stubFirstRunServer(vapidKey: () => Response = () => jsonResponse(200, V
|
||||
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/home') return Promise.resolve(jsonResponse(200, HOME))
|
||||
if (method === 'GET' && path === '/api/v1/push/vapid-public-key') return Promise.resolve(vapidKey())
|
||||
if (method === 'POST' && path === '/api/v1/push/subscriptions') {
|
||||
subscriptions.push(JSON.parse(String(init?.body)))
|
||||
@@ -110,7 +111,7 @@ async function renderStep(vapidKey?: () => Response) {
|
||||
<AuthBootstrap>
|
||||
<Routes>
|
||||
<Route path="/onboarding/notifications" element={<NotificationsStep />} />
|
||||
<Route path="/" element={<h1>Home</h1>} />
|
||||
<Route path="/" element={<h1>Tonight's quest</h1>} />
|
||||
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
||||
</Routes>
|
||||
</AuthBootstrap>
|
||||
@@ -134,7 +135,7 @@ async function finishFromResult(server: ReturnType<typeof stubFirstRunServer>) {
|
||||
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(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
}
|
||||
|
||||
function useIphoneSafariTab() {
|
||||
@@ -210,7 +211,7 @@ describe('onboarding step 3', () => {
|
||||
expect(later()).toBeDisabled()
|
||||
completed(server.held[0]!)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -314,7 +315,7 @@ describe('onboarding step 3', () => {
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(server.subscriptions).toEqual([])
|
||||
@@ -358,7 +359,7 @@ describe('onboarding step 3', () => {
|
||||
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()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -61,6 +61,17 @@ export const GOOGLE_NOT_CONNECTED = {
|
||||
last_sync_error: null,
|
||||
}
|
||||
|
||||
// HOME is a GET /api/v1/home body as mock 1g shows it: Pizza night, a
|
||||
// Regular, tonight; ten Side quests; next week's Supply run ready; an
|
||||
// unread update.
|
||||
export const HOME = {
|
||||
dateLabel: 'Friday, Oct 2',
|
||||
tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 },
|
||||
sideQuestCount: 10,
|
||||
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },
|
||||
recentUpdates: { unread: true },
|
||||
}
|
||||
|
||||
// NOTIFICATION_SETTINGS is a GET /api/v1/settings/notifications body: the
|
||||
// seed channels and reminder time.
|
||||
export const NOTIFICATION_SETTINGS = {
|
||||
|
||||
Reference in New Issue
Block a user