Open the Supply run from the Plan and keep refused toggles shown (Feature 17)
Address review findings on the Supply run screen: - The Plan's Shopping list action is now a link to /plan/shopping?week=<the week shown>, an outline action from tablet up and a grape link under the Draft board on mobile. It reads "Shopping list · N" with the count still to buy once the week has a Supply run, and plain "Shopping list" until then; the screen offers to make the list. The "arrives once the Supply run is built" tooltip is gone. - The kicker is "Supply run · Oct 5 – 11", the week's dates from weekLabel, as in mocks 1l, 2b and 2f. - A refused toggle stays shown until a later toggle of its item supersedes it, such as its Retry; a later toggle of another item succeeding no longer hides it. Toggles are kept (gcTime Infinity) so a failure is not collected unseen, and a settled toggle removes the earlier toggles of its item. - A toggle refused with 409 archived invalidates the Supply run, which is read again and renders read-only with the Archived tag; its failure is shown without Retry. Maestro task 40cc2de5-6628-4b32-a491-ab8e02911d88. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -258,8 +258,8 @@
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* An action with nothing behind it yet (the shopping list until Feature
|
||||
17): it keeps focus, so hovering, tapping or focusing it shows the
|
||||
/* An action with nothing behind it yet (Send to Google Chat until Feature
|
||||
19): it keeps focus, so hovering, tapping or focusing it shows the
|
||||
tooltip. */
|
||||
.unavailable {
|
||||
position: relative;
|
||||
@@ -414,6 +414,21 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The way to the week's Supply run under the Draft board on mobile. */
|
||||
.shoppingLink {
|
||||
display: inline-flex;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* A push failure's way to the Google Calendar settings. */
|
||||
.settingsLink {
|
||||
color: inherit;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
@@ -100,8 +100,12 @@ const lists = {
|
||||
jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }),
|
||||
}
|
||||
|
||||
// SUPPLY_RUN is the Supply run of a week, as GET /api/v1/shopping/{weekStart}.
|
||||
const SUPPLY_RUN = /^GET \/api\/v1\/shopping\/\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||
// the household settings; any other request fails the test.
|
||||
// the household settings, and no Supply run (404) for a week given none;
|
||||
// any other request fails the test.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
@@ -110,7 +114,7 @@ function stubServer(responders: Record<string, Responder>) {
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : SUPPLY_RUN.test(key) ? () => errorResponse(404, 'not_found') : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
@@ -139,13 +143,37 @@ async function renderPage() {
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<PlanPage />
|
||||
<Routes>
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route path="/plan/shopping" element={<Opened />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// Opened stands in for the screen a link opened, saying where it is.
|
||||
function Opened() {
|
||||
const { pathname, search } = useLocation()
|
||||
return <p>Opened {pathname + search}</p>
|
||||
}
|
||||
|
||||
// supplyRun is the Supply run of the week of Oct 5 with needed of its 32
|
||||
// items still to buy.
|
||||
function supplyRun(needed: number) {
|
||||
return {
|
||||
weekStart: '2026-10-05',
|
||||
label: 'Week 41 · Oct 5 – 11',
|
||||
title: null,
|
||||
status: 'current',
|
||||
sent_to_chat_at: null,
|
||||
needed_count: needed,
|
||||
total_count: 32,
|
||||
groups: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
|
||||
// the page opens on this week.
|
||||
beforeEach(() => {
|
||||
@@ -729,6 +757,35 @@ describe('PlanPage', () => {
|
||||
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("links to the week's Supply run under the Draft board, saying how many items are still to buy", async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(19)),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
|
||||
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
expect(declaredStyle(shopping).color).toBe('var(--color-accent-700)')
|
||||
// It follows the push button and the sync status.
|
||||
const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
|
||||
expect(push.compareDocumentPosition(shopping) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
fireEvent.click(shopping)
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links to the Supply run with no count while the week has no shopping list', async () => {
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
await waitFor(() => expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain('/api/v1/shopping/2026-10-05'))
|
||||
const shopping = screen.getByRole('link', { name: 'Shopping list' })
|
||||
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
fireEvent.click(shopping)
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves between weeks with the previous and next week buttons', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
@@ -1026,11 +1083,11 @@ describe.each([
|
||||
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
|
||||
|
||||
// Actions: the shopping list unavailable, saying why; Rebuild week
|
||||
// outlined and Push to Calendar primary, both working.
|
||||
const shopping = screen.getByRole('button', { name: 'Shopping list' })
|
||||
expect(shopping).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
|
||||
// Actions: the shopping list, with no count while the week has no
|
||||
// Supply run, and Rebuild week outlined; Push to Calendar primary.
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list' })
|
||||
expect(shopping).not.toHaveAttribute('aria-disabled')
|
||||
expect(shopping).not.toHaveAccessibleDescription()
|
||||
const push = screen.getByRole('button', { name: 'Push to Calendar' })
|
||||
expect(push).toBeEnabled()
|
||||
expect(push).not.toHaveAttribute('aria-disabled')
|
||||
@@ -1038,7 +1095,6 @@ describe.each([
|
||||
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
|
||||
expect(rebuild).toBeEnabled()
|
||||
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
|
||||
fireEvent.click(shopping)
|
||||
expect(posts(fetchMock)).toEqual([])
|
||||
// Mobile's own pieces are not shown.
|
||||
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
|
||||
@@ -1098,6 +1154,27 @@ describe.each([
|
||||
expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago')
|
||||
})
|
||||
|
||||
it("opens the week's Supply run from the Shopping list action, saying how many items are still to buy", async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(28)),
|
||||
...lists,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
|
||||
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
expect(declaredStyle(shopping).borderColor).toBe('var(--color-neutral-300)')
|
||||
// Another week's action opens that week's, which has no list yet.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-12')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
fireEvent.click(await screen.findByRole('link', { name: 'Shopping list · 28' }))
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says changes to a pushed plan are not on Google Calendar', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists })
|
||||
await renderPage()
|
||||
|
||||
+27
-14
@@ -7,8 +7,10 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button, type ButtonVariant } from '../ui/Button.tsx'
|
||||
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'
|
||||
@@ -79,10 +81,6 @@ const settingsFailures: Record<string, { reason: string; fix: string }> = {
|
||||
},
|
||||
}
|
||||
|
||||
// SHOPPING_HINT is why the shopping list is unavailable.
|
||||
// TODO(Feature 17): open the Supply run, with its count, once it is built.
|
||||
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
|
||||
|
||||
// syncStatuses is the plan manager's line on whether the plan is on Calendar.
|
||||
const syncStatuses: Record<PlanStatus, string> = {
|
||||
draft: 'Not pushed to Google Calendar yet',
|
||||
@@ -195,6 +193,22 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone
|
||||
)
|
||||
}
|
||||
|
||||
// ShoppingList opens the week's Supply run: an outline action in the plan
|
||||
// manager's header, a grape link under the Draft board on mobile. Once the
|
||||
// week has a Supply run it says how many items are still to buy, "Shopping
|
||||
// list · 28"; until then, or while that is read, it says no count, and the
|
||||
// Supply run screen offers to make the list.
|
||||
function ShoppingList({ weekStart, layout }: { weekStart: string; layout: Breakpoint }) {
|
||||
const run = useSupplyRun(weekStart)
|
||||
const className = layout === 'mobile' ? styles.shoppingLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
|
||||
return (
|
||||
<Link to={supplyRunScreen(weekStart)} className={className}>
|
||||
<Icon icon={ShoppingCart} size="inline" />
|
||||
{run.data ? `Shopping list · ${run.data.needed_count}` : 'Shopping list'}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// SkeletonTiles stands in for the seven days while the plan loads or the AI
|
||||
// drafts it, as two columns of tiles on mobile and seven columns wider.
|
||||
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
|
||||
@@ -217,9 +231,10 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
|
||||
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
|
||||
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
|
||||
//
|
||||
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to
|
||||
// Calendar and the unavailable Shopping list are actions in the header,
|
||||
// over the plan's sync status and the 7-column grid.
|
||||
// From tablet up it is the plan manager (mocks 2c, 2d): Shopping list,
|
||||
// Rebuild week and Push to Calendar are actions in the header, over the
|
||||
// plan's sync status and the 7-column grid. On mobile the Shopping list
|
||||
// link sits under the push button.
|
||||
//
|
||||
// Push to Calendar, on either layout, sends the plan shown and shows it as
|
||||
// synced once the server answers; a failure says why, linking to the Google
|
||||
@@ -462,6 +477,7 @@ function WeekBoard({
|
||||
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
|
||||
</Button>
|
||||
<SyncStatus plan={plan.data} className={styles.mobileStatus} />
|
||||
<ShoppingList weekStart={weekStart} layout={layout} />
|
||||
{sheet}
|
||||
</>
|
||||
)
|
||||
@@ -493,10 +509,7 @@ function WeekBoard({
|
||||
<div className={styles.actionBar}>
|
||||
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
|
||||
<div className={styles.actions}>
|
||||
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
|
||||
<Icon icon={ShoppingCart} size="inline" />
|
||||
Shopping list
|
||||
</Unavailable>
|
||||
<ShoppingList weekStart={weekStart} layout={layout} />
|
||||
<Button
|
||||
variant="outline"
|
||||
className={styles.action}
|
||||
@@ -600,9 +613,9 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
|
||||
)
|
||||
}
|
||||
|
||||
// Unavailable is an action with nothing behind it yet (the shopping list,
|
||||
// Send to Google Chat): it stays focusable, so a tap or focus shows the
|
||||
// tooltip saying why, above it or, at the top of the page, below.
|
||||
// Unavailable is an action with nothing behind it yet (Send to Google
|
||||
// Chat): it stays focusable, so a tap or focus shows the tooltip saying
|
||||
// why, above it or, at the top of the page, below.
|
||||
export function Unavailable({
|
||||
hint,
|
||||
variant = 'primary',
|
||||
|
||||
@@ -140,8 +140,8 @@ describe('SupplyRunPage', () => {
|
||||
|
||||
const title = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
|
||||
expect(declaredStyle(title).fontFamily).toBe('var(--font-heading)')
|
||||
// The kicker is the API's week label, set in capitals by the stylesheet.
|
||||
const kicker = screen.getByText('Supply run · Week 41 · Oct 5 – 11')
|
||||
// The kicker is the week's dates, set in capitals by the stylesheet.
|
||||
const kicker = screen.getByText('Supply run · Oct 5 – 11')
|
||||
expect(declaredStyle(kicker).textTransform).toBe('uppercase')
|
||||
expect(declaredStyle(kicker).color).toBe('var(--color-accent-700)')
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
@@ -268,6 +268,63 @@ describe('SupplyRunPage', () => {
|
||||
expect(checkbox('Basmati rice')).toBeChecked()
|
||||
})
|
||||
|
||||
it('keeps a refused toggle shown when a later toggle of another item succeeds, until it is retried', async () => {
|
||||
let patches = 0
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
|
||||
[`PATCH ${ITEM('i3')}`]: () => {
|
||||
patches++
|
||||
return patches === 1 ? errorResponse(500, 'internal') : jsonResponse(200, { id: 'i3', needed: true, needed_count: 6, total_count: 6 })
|
||||
},
|
||||
[`PATCH ${ITEM('i6')}`]: () => jsonResponse(200, { id: 'i6', needed: true, needed_count: 5, total_count: 6 }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
|
||||
fireEvent.click(checkbox('Basmati rice'))
|
||||
await waitFor(() => expect(screen.getByText('5 / 6')).toBeInTheDocument())
|
||||
expect(checkbox('Basmati rice')).toBeChecked()
|
||||
// The refusal is still shown: nothing has superseded it.
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.getByText('6 / 6')).toBeInTheDocument())
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
})
|
||||
|
||||
it('reads the list again when a toggle is refused because the week was archived, and shows it read-only', async () => {
|
||||
let reads = 0
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => {
|
||||
reads++
|
||||
return jsonResponse(200, supplyRun(reads === 1 ? {} : { status: 'archived' }))
|
||||
},
|
||||
[`PATCH ${ITEM('i3')}`]: () => errorResponse(409, 'archived'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(reads).toBe(2)
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
|
||||
// It says why, with nothing to retry.
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
|
||||
expect(within(alert).queryByRole('button')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Taco seasoning'))
|
||||
expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`])
|
||||
})
|
||||
|
||||
it('shows the status line once the list was sent to Google Chat', async () => {
|
||||
// Saturday Oct 10, 8:00 am in New York.
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })) })
|
||||
@@ -335,7 +392,7 @@ describe('SupplyRunPage', () => {
|
||||
})
|
||||
await renderPage('/plan/shopping?week=2026-09-28')
|
||||
|
||||
expect(await screen.findByText('Supply run · Week 40 · Sep 28 – Oct 4')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Supply run · Sep 28 – Oct 4')).toBeInTheDocument()
|
||||
expect(screen.getByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Archive, Check, MessageSquare } from 'lucide-react'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { Unavailable } from '../plan/PlanPage.tsx'
|
||||
import { defaultWeek, localNow, weekdayKicker, weekStart as weekOf } from '../plan/week.ts'
|
||||
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
@@ -11,16 +11,20 @@ import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import { useGenerateSupplyRun, useSupplyRun, useToggles, type SupplyRunGroup, type SupplyRunItem, type Toggles } from './api.ts'
|
||||
import {
|
||||
useGenerateSupplyRun,
|
||||
useSupplyRun,
|
||||
useToggles,
|
||||
WEEK_PARAM,
|
||||
type SupplyRunGroup,
|
||||
type SupplyRunItem,
|
||||
type Toggles,
|
||||
} from './api.ts'
|
||||
import styles from './SupplyRunPage.module.css'
|
||||
|
||||
// SUPPLY_ARCHIVE is the Supply archive screen.
|
||||
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
|
||||
|
||||
// WEEK_PARAM names the search parameter choosing the week shown, its Monday
|
||||
// as YYYY-MM-DD: /plan/shopping?week=2026-09-28.
|
||||
export const WEEK_PARAM = 'week'
|
||||
|
||||
// CHAT_HINT is why Send to Google Chat is unavailable.
|
||||
// TODO(Feature 19): send the Supply run to Google Chat once it is built.
|
||||
const CHAT_HINT = 'Sending the shopping list to Google Chat arrives in a later update.'
|
||||
@@ -100,12 +104,12 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
|
||||
}
|
||||
|
||||
// SupplyRunList is a week's Supply run (mocks 1l, 2b, 2f): the kicker with
|
||||
// the week label, the title with the count still to buy of all items, and
|
||||
// the week's dates, the title with the count still to buy of all items, and
|
||||
// one card per dinner, stacked on mobile and in three masonry columns from
|
||||
// tablet up, where Archive and Send to Google Chat are actions in the
|
||||
// header. On a current Supply run each item toggles at once (useToggles); a
|
||||
// toggle refused puts the item back and says why. An archived one is
|
||||
// read-only.
|
||||
// read-only, and a toggle it refused is not offered again.
|
||||
function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; timezone: string; layout: Breakpoint }) {
|
||||
const toggles = useToggles(weekStart)
|
||||
const run = toggles.shown
|
||||
@@ -118,7 +122,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
|
||||
<div className={`${styles.page} ${styles[layout]}`}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.heading}>
|
||||
<p className={styles.kicker}>Supply run · {run.label}</p>
|
||||
<p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p>
|
||||
<div className={styles.titleRow}>
|
||||
<h1 className={styles.title}>Shopping list</h1>
|
||||
{archived && <Tag tone="grape">Archived</Tag>}
|
||||
@@ -145,7 +149,7 @@ function SupplyRunList({ weekStart, timezone, layout }: { weekStart: string; tim
|
||||
)}
|
||||
</header>
|
||||
{failed !== null && (
|
||||
<ErrorBanner onRetry={() => toggles.toggle(failed.toggle)}>
|
||||
<ErrorBanner onRetry={archived ? undefined : () => toggles.toggle(failed.toggle)}>
|
||||
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
|
||||
{failed.error.message}
|
||||
</ErrorBanner>
|
||||
|
||||
+36
-4
@@ -6,6 +6,16 @@ import { oneOf } from '../dinners/api.ts'
|
||||
|
||||
export const SHOPPING_PATH = '/api/v1/shopping'
|
||||
|
||||
// WEEK_PARAM names the search parameter choosing the week the Supply run
|
||||
// screen shows, its Monday as YYYY-MM-DD: /plan/shopping?week=2026-09-28.
|
||||
export const WEEK_PARAM = 'week'
|
||||
|
||||
// supplyRunScreen is the Supply run screen on the week starting on Monday
|
||||
// weekStart.
|
||||
export function supplyRunScreen(weekStart: string): string {
|
||||
return `/plan/shopping?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
|
||||
}
|
||||
|
||||
// supplyRunPath is the Supply run of the week starting on Monday weekStart.
|
||||
export function supplyRunPath(weekStart: string): string {
|
||||
return `${SHOPPING_PATH}/${encodeURIComponent(weekStart)}`
|
||||
@@ -165,8 +175,8 @@ function toggleKey(weekStart: string) {
|
||||
}
|
||||
|
||||
// Toggles is the toggles of one week's Supply run: shown, the Supply run
|
||||
// with those still pending laid over it, the latest toggle if it failed,
|
||||
// and toggle to make another.
|
||||
// with those still pending laid over it, the latest failed toggle no later
|
||||
// toggle of its item has superseded, and toggle to make another.
|
||||
export type Toggles = {
|
||||
shown: SupplyRun
|
||||
failed: { toggle: Toggle; error: Error } | null
|
||||
@@ -193,6 +203,14 @@ export type Toggles = {
|
||||
// before it and none after. Each is sent once, and not held back while the
|
||||
// device is offline (networkMode always): an item never shows a state for
|
||||
// longer than it takes the server to refuse it.
|
||||
//
|
||||
// A failed toggle stays failed, and shown, until a later toggle of its item
|
||||
// supersedes it, such as its Retry: a toggle of another item succeeding
|
||||
// does not hide it. So that a failure is never collected unseen, toggles are
|
||||
// kept (gcTime Infinity), and a settled toggle removes the earlier toggles
|
||||
// of its item it supersedes. A toggle refused because the week was frozen
|
||||
// into the Supply archive (409 archived) reads the Supply run again, which
|
||||
// then renders read-only.
|
||||
export function useToggles(weekStart: string): Toggles {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -207,12 +225,15 @@ export function useToggles(weekStart: string): Toggles {
|
||||
.getMutationCache()
|
||||
.findAll({ mutationKey })
|
||||
.map((mutation) => ({ status: mutation.state.status, toggle: mutation.state.variables as Toggle, error: mutation.state.error }))
|
||||
const latest = toggles.at(-1)
|
||||
const failed = toggles
|
||||
.filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id))
|
||||
.at(-1)
|
||||
const { mutate } = useMutation({
|
||||
mutationKey,
|
||||
scope: { id: mutationKey.join(' ') },
|
||||
networkMode: 'always',
|
||||
retry: false,
|
||||
gcTime: Infinity,
|
||||
mutationFn: async ({ id, needed }: Toggle): Promise<Counts> => {
|
||||
const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}`
|
||||
const where = `PATCH ${path}`
|
||||
@@ -237,10 +258,21 @@ export function useToggles(weekStart: string): Toggles {
|
||||
},
|
||||
)
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError && error.code === 'archived') void queryClient.invalidateQueries({ queryKey: key })
|
||||
},
|
||||
// This toggle is still pending as it settles, and those after it wait
|
||||
// for it: the settled toggles of its item are the earlier ones.
|
||||
onSettled: (_counts, _error, { id }) => {
|
||||
const cache = queryClient.getMutationCache()
|
||||
for (const earlier of cache.findAll({ mutationKey, predicate: (m) => m.state.status !== 'pending' })) {
|
||||
if ((earlier.state.variables as Toggle).id === id) cache.remove(earlier)
|
||||
}
|
||||
},
|
||||
})
|
||||
return {
|
||||
shown: shown(run, toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)),
|
||||
failed: latest?.status === 'error' ? { toggle: latest.toggle, error: latest.error! } : null,
|
||||
failed: failed === undefined ? null : { toggle: failed.toggle, error: failed.error! },
|
||||
toggle: (toggle) => {
|
||||
mutate(toggle)
|
||||
renderAgain()
|
||||
|
||||
Reference in New Issue
Block a user