From c454c76816106754137f3f7e082e066cfc7b6b31 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Thu, 8 Oct 2026 21:31:21 -0400 Subject: [PATCH] Add to this week and favorite (Feature 15) (#147) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/dinners/AddToWeekSheet.module.css | 7 + web/src/dinners/AddToWeekSheet.tsx | 173 ++++++++ web/src/dinners/DinnerDetailActions.test.tsx | 406 +++++++++++++++++++ web/src/dinners/DinnerDetailPage.module.css | 21 +- web/src/dinners/DinnerDetailPage.test.tsx | 7 +- web/src/dinners/DinnerDetailPage.tsx | 101 ++++- web/src/dinners/api.ts | 47 ++- web/src/dinners/detail.ts | 3 +- web/src/plan/PlanPage.tsx | 29 +- web/src/plan/api.ts | 36 +- 10 files changed, 773 insertions(+), 57 deletions(-) create mode 100644 web/src/dinners/AddToWeekSheet.module.css create mode 100644 web/src/dinners/AddToWeekSheet.tsx create mode 100644 web/src/dinners/DinnerDetailActions.test.tsx diff --git a/web/src/dinners/AddToWeekSheet.module.css b/web/src/dinners/AddToWeekSheet.module.css new file mode 100644 index 0000000..80ffbfc --- /dev/null +++ b/web/src/dinners/AddToWeekSheet.module.css @@ -0,0 +1,7 @@ +/* A week with no plan: why there's no day, over the link to draft it. */ +.noPlan { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 14px; +} diff --git a/web/src/dinners/AddToWeekSheet.tsx b/web/src/dinners/AddToWeekSheet.tsx new file mode 100644 index 0000000..0998ae2 --- /dev/null +++ b/web/src/dinners/AddToWeekSheet.tsx @@ -0,0 +1,173 @@ +import { useId, useState } from 'react' +import { Link } from 'react-router' +import { usePlan, useWeekSwap, type PlanDinner, type Swap } from '../plan/api.ts' +import { cooking } from '../plan/PlanPage.tsx' +import swapStyles from '../plan/SwapSheet.module.css' +import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts' +import { useHouseholdSettings } from '../settings/api.ts' +import buttonStyles from '../ui/Button.module.css' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { ListRow } from '../ui/ListRow.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import type { DinnerDetail } from './detail.ts' +import styles from './AddToWeekSheet.module.css' + +// PLAN is the Draft board, where a week with no plan is drafted. +export const PLAN = '/plan' + +// Which is the week a dinner is added to: the household's current week or +// the one after. +export type Which = 'this' | 'next' + +export const weekNames: Record = { this: 'this week', next: 'next week' } + +// Added is a swap sent from the sheet: the week it went to and the swap. +export type Added = { week: string; which: Which; swap: Swap } + +// planDinner is the dinner as it is planned on a day. +function planDinner(dinner: DinnerDetail): PlanDinner { + return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method } +} + +type WeekDaysProps = { + dinner: DinnerDetail + week: string + which: Which + onAdd: (added: Added) => void +} + +// WeekDays is the seven days of the week starting on week, each with its +// dinner; choosing one swaps the dinner in as the Draft board swaps a day +// (useWeekSwap). A week with no plan offers to draft it on the Draft board: +// nothing here makes a plan up. A dinner already on the week can't be added +// again, nor can one while a swap of the week is still being saved or the +// week is being pushed to Calendar. +function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) { + const plan = usePlan(week) + const { swap, push, refused, swapIn } = useWeekSwap(week) + + if (plan.isError) { + return void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message} + } + if (plan.isPending) { + return ( +
+ {Array.from({ length: 7 }, (_, i) => ( + + ))} +
+ ) + } + if (plan.data === null) { + return ( +
+

There's no plan for {weekNames[which]} yet, so there's no day to add it to.

+ + Draft {weekNames[which]} first + +
+ ) + } + + const days = plan.data.days + const planned = days.find((day) => day.dinner.id === dinner.id) + const choosable = planned === undefined && !refused + function add(date: string) { + const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' } + swapIn(chosen) + onAdd({ week, which, swap: chosen }) + } + return ( + <> + {planned !== undefined ? ( +

+ {dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}. +

+ ) : swap.pending ? ( +

A change to {weekNames[which]} is still being saved.

+ ) : ( + push.pending &&

The plan for {weekNames[which]} is being pushed to Calendar.

+ )} + + + ) +} + +export type AddToWeekSheetProps = { + dinner: DinnerDetail + onAdd: (added: Added) => void + onClose: () => void +} + +// AddToWeekSheet is the sheet Add to this week opens: the seven days of the +// household's current week, Monday to Sunday in its timezone, each with its +// dinner, and a toggle to next week. Choosing a day replaces its dinner. +export function AddToWeekSheet({ dinner, onAdd, onClose }: AddToWeekSheetProps) { + const titleId = useId() + const settings = useHouseholdSettings() + const [which, setWhich] = useState('this') + + let body + if (settings.isError) { + body = ( + void settings.refetch()}> + Couldn't load the household settings. {settings.error.message} + + ) + } else if (settings.isPending) { + body = + } else { + const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date) + const week = which === 'this' ? thisWeek : addDays(thisWeek, 7) + body = ( + <> +

+ {weekLabel(week).dates} · replaces the day's dinner +

+ + + + ) + } + + return ( + +
+

+ Add {dinner.name} +

+ {body} +
+
+ ) +} diff --git a/web/src/dinners/DinnerDetailActions.test.tsx b/web/src/dinners/DinnerDetailActions.test.tsx new file mode 100644 index 0000000..dc967a4 --- /dev/null +++ b/web/src/dinners/DinnerDetailActions.test.tsx @@ -0,0 +1,406 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } 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' +import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { PlanPage } from '../plan/PlanPage.tsx' +import { DinnerDetailPage } from './DinnerDetailPage.tsx' + +const DINNER = '/api/v1/dinners/d1' +const FAVORITE = `${DINNER}/favorite` +const THIS_WEEK = '/api/v1/plans/2026-10-05' +const NEXT_WEEK = '/api/v1/plans/2026-10-12' +const THURSDAY = `${THIS_WEEK}/days/2026-10-08` + +// detail is the Korean beef bowls, a Quest-log Side quest, as GET +// /api/v1/dinners/{id} sends it. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: null, + ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }], + steps: ['Brown the beef with the sauce.'], + source: null, + grounding_status: 'grounded', + grounding_error: null, + why_picked: null, + ...overrides, + } +} + +// listed is the bowls as the dinners list, and so the favorite toggle, +// answers: no recipe. +function listed(is_favorite: boolean) { + return { + id: 'd1', + name: 'Korean beef bowls', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + is_favorite, + photo_url: null, + grounding_status: 'grounded', + } +} + +const THIS_NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Lemon orzo', 'Chicken stir-fry', 'Pizza night', 'Fish tacos', 'Sheet-pan salmon'] +const NEXT_NAMES = ['Chili', 'Pad thai', 'Burgers', 'Ramen', 'Pizza night', 'Pot roast', 'Fried rice'] + +// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it, with +// dinner ids p0 to p6 unless ids says otherwise. +function planBody(weekStart: string, names: string[], ids = names.map((_, i) => `p${i}`)) { + const start = new Date(`${weekStart}T00:00:00Z`).getTime() + return { + id: `plan-${weekStart}`, + week_start: weekStart, + status: 'draft', + ai_note: null, + calendar_synced_at: null as string | null, + days: names.map((name, i) => ({ + date: new Date(start + i * 86_400_000).toISOString().slice(0, 10), + dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' }, + is_new: false, + start_by: null, + })), + } +} + +// THURSDAY_SWAPPED is this week's plan as the swap of Thursday answers it. +function thursdaySwapped() { + const ids = THIS_NAMES.map((_, i) => (i === 3 ? 'd1' : `p${i}`)) + const names = THIS_NAMES.map((name, i) => (i === 3 ? 'Korean beef bowls' : name)) + const plan = planBody('2026-10-05', names, ids) + plan.days[3]!.is_new = true + return plan +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me, the +// household settings and the dinner; any other request fails the test. +function stubServer(responders: Record = {}) { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), + [`GET ${DINNER}`]: () => jsonResponse(200, detail()), + ...responders, + } + const fetchMock = vi.fn((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}`) + return Promise.resolve(respond(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// sent lists the requests sent with method, as "path body". +function sent(fetchMock: Mock, method: string) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === method) + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) +} + +function reads(fetchMock: Mock, path: string) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length +} + +// deferred is a response the test sends when it chooses. +function deferred() { + let send!: (response: Response) => void + const response = new Promise((resolve) => { + send = resolve + }) + return { response, send } +} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the bowls' detail screen, with plan for the Plan: a probe unless told +// otherwise. It answers with the router. +async function renderPage(plan =

Plan probe

) { + const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const router = createMemoryRouter( + [ + { path: '/dinner/:id', element: }, + { path: '/plan', element: plan }, + ], + { initialEntries: ['/dinner/d1'] }, + ) + render( + + + + + , + ) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + return router +} + +// openSheet opens Add to this week and waits for the week's days. +async function openSheet(listName = 'Days of this week') { + fireEvent.click(screen.getByRole('button', { name: 'Add to this week' })) + const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' }) + const days = await within(sheet).findByRole('list', { name: listName }) + return { sheet, days } +} + +// dayRows is each day's row text, as "Monday 5 · Beef tacos · Stovetop · 30 min", +// with plain spaces. +function dayRows(days: HTMLElement) { + return within(days) + .getAllByRole('listitem') + .map((row) => [...row.querySelectorAll('span')].filter((span) => span.children.length === 0 && !span.closest('[aria-hidden="true"]')).map((span) => span.textContent).join(' · ').replace(/\s/g, ' ')) +} + +beforeEach(() => { + mockViewport(390) + // Wednesday Oct 7, noon in New York. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() + Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() +}) + +describe('Add to this week', () => { + it('lists the seven days of this week with their dinners, and next week by the toggle', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`GET ${NEXT_WEEK}`]: () => jsonResponse(200, planBody('2026-10-12', NEXT_NAMES)), + }) + await renderPage() + // Nothing about the week is read until the sheet opens. + expect(reads(fetchMock, THIS_WEEK)).toBe(0) + + const { sheet, days } = await openSheet() + expect(within(sheet).getByText("Oct 5 – 11 · replaces the day's dinner")).toBeInTheDocument() + expect(dayRows(days)).toEqual([ + 'Monday 5 · Beef tacos · Stovetop · 30 min', + 'Tuesday 6 · Spaghetti bolognese · Stovetop · 30 min', + 'Wednesday 7 · Lemon orzo · Stovetop · 30 min', + 'Thursday 8 · Chicken stir-fry · Stovetop · 30 min', + 'Friday 9 · Pizza night · Stovetop · 30 min', + 'Saturday 10 · Fish tacos · Stovetop · 30 min', + 'Sunday 11 · Sheet-pan salmon · Stovetop · 30 min', + ]) + expect(within(days).getAllByRole('button')).toHaveLength(7) + + fireEvent.click(within(sheet).getByRole('radio', { name: 'Next week' })) + const next = await within(sheet).findByRole('list', { name: 'Days of next week' }) + expect(within(sheet).getByText("Oct 12 – 18 · replaces the day's dinner")).toBeInTheDocument() + expect(dayRows(next)[0]).toBe('Monday 12 · Chili · Stovetop · 30 min') + expect(dayRows(next)[6]).toBe('Sunday 18 · Fried rice · Stovetop · 30 min') + + fireEvent.click(within(sheet).getByRole('radio', { name: 'This week' })) + expect(await within(sheet).findByRole('list', { name: 'Days of this week' })).toBeInTheDocument() + }) + + it("takes this week in the household's timezone, not the device's UTC date", async () => { + // Sunday Oct 11, 10 pm in New York, already Monday Oct 12 in UTC. + vi.setSystemTime(new Date('2026-10-12T02:00:00Z')) + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)) }) + await renderPage() + + const { days } = await openSheet() + expect(dayRows(days)[6]).toBe('Sunday 11 · Sheet-pan salmon · Stovetop · 30 min') + expect(reads(fetchMock, THIS_WEEK)).toBe(1) + expect(reads(fetchMock, NEXT_WEEK)).toBe(0) + }) + + it('swaps the day chosen through the plan API, confirms it and reads the plan again', async () => { + const swap = deferred() + let plan = planBody('2026-10-05', THIS_NAMES) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`PUT ${THURSDAY}`]: () => swap.response, + }) + await renderPage() + const { days } = await openSheet() + + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => expect(sent(fetchMock, 'PUT')).toHaveLength(1)) + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`]) + expect(screen.getByRole('status')).toHaveTextContent('Adding to Thursday…') + + plan = thursdaySwapped() + await act(async () => swap.send(jsonResponse(200, plan))) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + // The plan query is invalidated: the week is read again from the server. + await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(2)) + + // The sheet opened again shows the plan as swapped. + const again = await openSheet() + expect(dayRows(again.days)[3]).toBe('Thursday 8 · Korean beef bowls · Stovetop · 30 min · This dinner') + expect(within(again.sheet).getByText('Korean beef bowls is already on Thursday this week.')).toBeInTheDocument() + expect(within(again.days).queryAllByRole('button')).toHaveLength(0) + }) + + it('offers to draft a week with no plan on the Plan, and swaps nothing', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found') }) + await renderPage() + + fireEvent.click(screen.getByRole('button', { name: 'Add to this week' })) + const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' }) + const draft = await within(sheet).findByRole('link', { name: 'Draft this week first' }) + expect(draft).toHaveAttribute('href', '/plan') + expect(within(sheet).getByText("There's no plan for this week yet, so there's no day to add it to.")).toBeInTheDocument() + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + + fireEvent.click(draft) + expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument() + expect(sent(fetchMock, 'PUT')).toEqual([]) + }) + + it('shows a refused swap with the error, puts the day back and retries it', async () => { + let refuse = true + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`PUT ${THURSDAY}`]: () => (refuse ? errorResponse(500, 'internal') : jsonResponse(200, thursdaySwapped())), + }) + await renderPage() + const { days } = await openSheet() + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't add it to Thursday this week. internal message") + const reopened = await openSheet() + expect(dayRows(reopened.days)[3]).toBe('Thursday 8 · Chicken stir-fry · Stovetop · 30 min') + fireEvent.keyDown(reopened.sheet, { key: 'Escape' }) + + refuse = false + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`]) + }) + + it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => { + let plan = planBody('2026-10-05', THIS_NAMES) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`POST ${THIS_WEEK}/push-calendar`]: () => { + plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' } + return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } }) + }, + [`PUT ${THURSDAY}`]: () => { + plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' } + return jsonResponse(200, plan) + }, + 'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'), + }) + const router = await renderPage() + + // The week is pushed on the Plan, which says its Supply run was made. + await act(() => router.navigate('/plan')) + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + // Coming back from the dinner with nothing swapped, it still says so. + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + await act(() => router.navigate('/plan')) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + + // Thursday is swapped from the dinner's Add to this week. + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + const { days } = await openSheet() + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + + // The Plan shows the swapped plan, not changed since its push, and no + // longer says the earlier plan's Supply run was made. + await act(() => router.navigate('/plan')) + expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument() + expect(screen.getByText('Korean beef bowls')).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument() + expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`]) + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`]) + }) + + it('refuses a swap while the week is being pushed to Calendar', async () => { + const push = deferred() + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`POST ${THIS_WEEK}/push-calendar`]: () => push.response, + 'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'), + }) + const router = await renderPage() + await act(() => router.navigate('/plan')) + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1)) + + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + const { sheet, days } = await openSheet() + expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument() + expect(within(days).queryAllByRole('button')).toHaveLength(0) + expect(sent(fetchMock, 'PUT')).toEqual([]) + }) +}) + +describe('the favorite heart', () => { + it('fills at once, keeps the recipe and takes the server answer', async () => { + const favorite = deferred() + const fetchMock = stubServer({ [`POST ${FAVORITE}`]: () => favorite.response }) + await renderPage() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toHaveAttribute('aria-pressed', 'false') + + fireEvent.click(heart) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'true')) + expect(sent(fetchMock, 'POST')).toEqual([`${FAVORITE} undefined`]) + + await act(async () => favorite.send(jsonResponse(200, listed(true)))) + await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1)) + expect(heart).toHaveAttribute('aria-pressed', 'true') + // The list-shaped answer did not replace the detail: the recipe stays. + expect(screen.getByText('Ground beef')).toBeInTheDocument() + expect(screen.getByText('Brown the beef with the sauce.')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('empties again with the error when the server refuses, and retries', async () => { + const refused = deferred() + let refuse = true + const fetchMock = stubServer({ + [`GET ${DINNER}`]: () => jsonResponse(200, detail({ is_favorite: true })), + [`POST ${FAVORITE}`]: () => (refuse ? refused.response : jsonResponse(200, listed(false))), + }) + await renderPage() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(heart) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false')) + await act(async () => refused.send(errorResponse(500, 'internal'))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't unfavorite Korean beef bowls. internal message") + expect(heart).toHaveAttribute('aria-pressed', 'true') + + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false')) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(sent(fetchMock, 'POST')).toHaveLength(2) + }) +}) diff --git a/web/src/dinners/DinnerDetailPage.module.css b/web/src/dinners/DinnerDetailPage.module.css index dd65df2..d88d3e6 100644 --- a/web/src/dinners/DinnerDetailPage.module.css +++ b/web/src/dinners/DinnerDetailPage.module.css @@ -207,6 +207,7 @@ bottom: 0; z-index: 1; display: flex; + flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 24px; @@ -214,16 +215,26 @@ background: var(--color-bg); } -.bar > :first-child { - flex: 1; -} - .add { - width: 100%; + flex: 1; min-height: 52px; font-size: 16px; } +/* What became of an add or a favorite: a full row above the buttons. */ +.status { + order: -1; + flex-basis: 100%; +} + +.added { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 600; + color: var(--color-accent-2-800); +} + .filled { color: var(--color-accent); fill: currentColor; diff --git a/web/src/dinners/DinnerDetailPage.test.tsx b/web/src/dinners/DinnerDetailPage.test.tsx index 5e93d46..52ee959 100644 --- a/web/src/dinners/DinnerDetailPage.test.tsx +++ b/web/src/dinners/DinnerDetailPage.test.tsx @@ -194,15 +194,14 @@ describe('DinnerDetailPage', () => { expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument() }) - it('holds Add to this week and the heart at the bottom, at rest until the next update', async () => { + it('holds Add to this week and the heart at the bottom', async () => { serve(detail({ is_favorite: true })) renderAt(['/dinner/d1']) const add = await screen.findByRole('button', { name: 'Add to this week' }) - expect(add).toHaveAttribute('aria-disabled', 'true') - expect(add).toHaveAccessibleDescription('Adding to this week and the favorite heart arrive in the next update.') + expect(add).toBeEnabled() const heart = screen.getByRole('button', { name: 'Favorite' }) - expect(heart).toBeDisabled() + expect(heart).toBeEnabled() expect(heart).toHaveAttribute('aria-pressed', 'true') const bar = heart.parentElement! expect(bar).toContainElement(add) diff --git a/web/src/dinners/DinnerDetailPage.tsx b/web/src/dinners/DinnerDetailPage.tsx index afbf78e..140236c 100644 --- a/web/src/dinners/DinnerDetailPage.tsx +++ b/web/src/dinners/DinnerDetailPage.tsx @@ -1,8 +1,13 @@ +import { useQueryClient } from '@tanstack/react-query' import { ChevronLeft, Heart } from 'lucide-react' +import { useEffect, useState } from 'react' import { Link, useLocation, useNavigate, useParams } from 'react-router' import { ApiError } from '../api/errors.ts' -import { duration, Unavailable } from '../plan/PlanPage.tsx' +import { planKey, usePlan, useWeekSwap } from '../plan/api.ts' +import { duration } from '../plan/PlanPage.tsx' +import { weekdayName } from '../plan/week.ts' import { useBreakpoint } from '../shell/useBreakpoint.ts' +import { Button } from '../ui/Button.tsx' import buttonStyles from '../ui/Button.module.css' import { EmptyState } from '../ui/EmptyState.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' @@ -11,17 +16,14 @@ import { IconButton } from '../ui/IconButton.tsx' import { Photo } from '../ui/Photo.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' -import type { Difficulty } from './api.ts' +import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx' +import { useToggleFavorite, type Difficulty } from './api.ts' import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts' import styles from './DinnerDetailPage.module.css' // REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed. export const REGULAR_DINNERS = '/settings/regulars' -// ADD_HINT is why Add to this week and the heart are unavailable. -// TODO(07423ba7): the add-to-week sheet and the favorite heart. -const ADD_HINT = 'Adding to this week and the favorite heart arrive in the next update.' - const difficultyLabels: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } // BackButton goes back in the app's history, or, opened from a link with @@ -125,20 +127,91 @@ function Recipe({ dinner }: { dinner: DinnerDetail }) { ) } +// AddedStatus says what became of a dinner added to a day: being saved, +// added, or why it couldn't be, with Retry, which sends the same swap again +// as the Draft board would (useWeekSwap), and is not offered while that is +// refused. The week's plan stays read while it shows, so a retry finds the +// day to swap; once added, the plan is read again from the server. +function AddedStatus({ added }: { added: Added }) { + const queryClient = useQueryClient() + usePlan(added.week) + const { swap, refused, swapIn } = useWeekSwap(added.week) + const { date } = added.swap + const done = swap.succeeded === added.swap + const failed = swap.failed?.swap === added.swap ? swap.failed.error : null + useEffect(() => { + if (done) void queryClient.invalidateQueries({ queryKey: planKey(added.week) }) + }, [done, queryClient, added.week]) + + if (failed !== null) { + return ( +
+ swapIn(added.swap)}> + Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message} + +
+ ) + } + return ( +

+ {done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`} +

+ ) +} + +// AddToWeek is Add to this week: it opens the sheet of the week's days, and +// says what became of the dinner added. +function AddToWeek({ dinner }: { dinner: DinnerDetail }) { + const [open, setOpen] = useState(false) + const [added, setAdded] = useState(null) + function add(chosen: Added) { + setOpen(false) + setAdded(chosen) + } + return ( + <> + {added !== null && } + + {open && setOpen(false)} />} + + ) +} + +// FavoriteHeart toggles whether the dinner is a favorite, at once, changing +// back with the error and Retry if the server refuses. One toggle is sent at +// a time. +function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) { + const favorite = useToggleFavorite() + function toggle() { + if (!favorite.isPending) favorite.mutate({ id: dinner.id, is_favorite: dinner.is_favorite }) + } + return ( + <> + {favorite.isError && ( +
+ + Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message} + +
+ )} + + + + + ) +} + // ActionBar is the bar at the bottom of the screen, the page's last child: // the page fills the scroll area so a short recipe still leaves it at the // bottom, and it sticks there over a long one. It holds Add to this week -// and the favorite heart, showing whether the dinner is a favorite. Both -// rest unavailable until the next update wires them. +// and the favorite heart, with what became of either above them. function ActionBar({ dinner }: { dinner: DinnerDetail }) { return (
- - Add to this week - - - - + +
) } diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts index 2a3b2b7..2c0c081 100644 --- a/web/src/dinners/api.ts +++ b/web/src/dinners/api.ts @@ -1,6 +1,7 @@ import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' +import type { DinnerDetail } from './detail.ts' export const DINNERS_PATH = '/api/v1/dinners' export const REGULARS_PATH = `${DINNERS_PATH}?status=regular` @@ -146,6 +147,11 @@ export function dinnerPath(id: string): string { return `${DINNERS_PATH}/${encodeURIComponent(id)}` } +// dinnerDetailKey is the query of dinner id with its recipe (useDinnerDetail). +export function dinnerDetailKey(id: string) { + return ['dinners', 'detail', id] as const +} + // 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 @@ -275,28 +281,43 @@ export function useEditRegular() { }) } -// 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. +// Favorite is the dinner whose heart is toggled, as it was before the toggle. +export type Favorite = { id: string; is_favorite: boolean } + +// setFavorite shows dinner id's heart as is_favorite wherever it is cached: +// in the Regulars and in its detail. A cache not yet read is left unread. +function setFavorite(queryClient: QueryClient, id: string, is_favorite: boolean) { + queryClient.setQueryData(REGULARS_LIST_KEY, (list) => + list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)), + ) + queryClient.setQueryData(dinnerDetailKey(id), (dinner) => dinner && { ...dinner, is_favorite }) +} + +// useToggleFavorite toggles a dinner's favorite heart (POST +// /api/v1/dinners/{id}/favorite), a Regular's or any dinner's on its detail +// screen. The heart changes at once and changes back if the server refuses. +// The server answers with the dinner as the dinners list sends it, which +// replaces the Regular; the detail keeps its recipe and takes only the heart. 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) => + mutationFn: async (dinner: Favorite) => parseDinner( `POST ${DINNERS_PATH}/{id}/favorite`, - await apiFetch(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }), + await apiFetch(`${dinnerPath(dinner.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)), - ) + onMutate: async (dinner) => { + await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) }) + await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY }) + setFavorite(queryClient, dinner.id, !dinner.is_favorite) }, - onSuccess: (dinner) => replace(queryClient, dinner), - onError: (_error, regular) => set(regular.id, regular.is_favorite), + onSuccess: (dinner) => { + queryClient.setQueryData(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r))) + queryClient.setQueryData(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite }) + }, + onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite), onSettled: () => settle(queryClient), }) } diff --git a/web/src/dinners/detail.ts b/web/src/dinners/detail.ts index 7e8ff2f..48ef513 100644 --- a/web/src/dinners/detail.ts +++ b/web/src/dinners/detail.ts @@ -4,6 +4,7 @@ import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { DIFFICULTIES, + dinnerDetailKey, dinnerPath, DINNERS_PATH, GROUNDING_POLL_MS, @@ -158,7 +159,7 @@ export function useDinnerDetail(id: string) { const { apiRead, network } = useApi() const offline = useSyncExternalStore(network.subscribe, network.offline) const query = useQuery({ - queryKey: ['dinners', 'detail', id], + queryKey: dinnerDetailKey(id), queryFn: async () => { const read = await apiRead(dinnerPath(id)) return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 87124ba..f6828b8 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -23,9 +23,8 @@ import { parseDraftResult, planKey, usePlan, - usePushToCalendar, useStartDraft, - useSwapDay, + useWeekSwap, type DraftResult, type Plan, type PlanDay, @@ -266,8 +265,7 @@ function WeekBoard({ const [jobId, setJobId] = useState(null) const job = useJob(DRAFT_JOB_KIND, jobId) const [confirming, setConfirming] = useState(false) - const swap = useSwapDay(weekStart) - const push = usePushToCalendar(weekStart) + const { swap, push, refused, swapIn: sendSwap } = useWeekSwap(weekStart) const run = useSupplyRun(weekStart) // Makes the week's Supply run again after a push could not make it. const generate = useGenerateSupplyRun(weekStart) @@ -431,25 +429,18 @@ function WeekBoard({ void run.refetch()}>Couldn't load this week's shopping list. {run.error.message} ) - // swapIn sends the swap chosen in the sheet and closes it. + // swapIn sends the swap chosen in the sheet, dropped on a day or retried + // (useWeekSwap) and closes the sheet. The Supply run made again on the + // Plan after a push is left behind with the push. function swapIn(chosen: Swap) { setSwappingDate(null) - push.reset() generate.reset() - swap.mutate(chosen) + sendSwap(chosen) } - // dropIn swaps in a dinner dragged onto a day. A drag is not picked up, - // nor dropped, while a swap of the week is pending. - function dropIn(chosen: Swap) { - swap.dismiss() - swapIn(chosen) - } - // openSwap opens the swap sheet on a day. One swap of the week is sent at - // a time, even across leaving the week and coming back: the plan the - // server answers it with must not overwrite another still pending. Nor is - // one sent while the week is being pushed to Calendar. + // openSwap opens the swap sheet on a day, unless a swap is refused now + // (useWeekSwap). A drag is not picked up, nor dropped, then either. function openSwap(date: string) { - if (swap.pending || pushing) return + if (refused) return swap.dismiss() setSwappingDate(date) } @@ -488,7 +479,7 @@ function WeekBoard({ body = layout !== 'mobile' ? ( <> - +
    {days.map((day) => ( void dismiss: () => void @@ -418,6 +420,7 @@ export function useSwapDay(weekStart: string): SwapDay { return { pending: swaps.some((swap) => swap.status === 'pending'), + succeeded: latest?.status === 'success' ? latest.swap : null, failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null, mutate, dismiss, @@ -529,6 +532,37 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { } } +// WeekSwap is the one way a day of a week is swapped, wherever the swap is +// chosen: the week's swaps and pushes, whether a swap is refused now, and +// swapIn to send one. +export type WeekSwap = { + swap: SwapDay + push: PushToCalendar + refused: boolean + swapIn: (chosen: Swap) => void +} + +// useWeekSwap swaps a day of the week starting on weekStart, from the Draft +// board or a dinner's Add to this week. One swap of the week is sent at a +// time, even across leaving the week and coming back: the plan the server +// answers it with must not overwrite another still pending. Nor is one sent +// while the week is being pushed to Calendar: refused says so, and swapIn +// then throws. A swap leaves the week's settled swaps behind, and its +// pushes: what a push said of Calendar and the Supply run was said of the +// plan before the swap, not the one swapped. +export function useWeekSwap(weekStart: string): WeekSwap { + const swap = useSwapDay(weekStart) + const push = usePushToCalendar(weekStart) + const refused = swap.pending || push.pending + function swapIn(chosen: Swap) { + if (refused) throw new Error(`The week of ${weekStart} has a swap or push pending, so ${chosen.date} cannot be swapped.`) + swap.dismiss() + push.reset() + swap.mutate(chosen) + } + return { swap, push, refused, swapIn } +} + // settleUnmadePushes is called as the Supply run of the week starting on // weekStart starts being made, from the Plan or the Supply run screen // (useGenerateSupplyRun). The function it answers with is called once the