From 2b5350bd62808b6e4f9370d0311b73c821fbd083 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 00:44:58 -0400 Subject: [PATCH 1/6] Add the Google Calendar settings screen and wire Push to Calendar (Feature 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /settings/calendar now shows the Google Calendar connection: "Connect Google Calendar" (a full-page navigation to the connect endpoint) when not connected; "Connected · {calendar}" in teal-800 with a picker of the writable calendars, saved on choice, and Disconnect behind a confirmation when connected; and a grape banner with Reconnect, no picker, when Google has refused the grant. The settings list's Google Calendar row reads the same connection instead of the placeholder endpoint. The plan screens' "Looks good — push to Calendar" and "Push to Calendar" buttons now POST /api/v1/plans/{weekStart}/push-calendar, showing progress and storing the synced plan the server answers with. Failures only the settings can fix (google_reconnect_required, no_calendar, not_connected, calendar_unavailable) link to /settings/calendar; plan_changed reads the plan again; any other failure offers Retry. The mobile Draft board shows the existing sync status line under its push button. Maestro task 677261bc-1a7c-4d96-b8f9-7601caf2186d. Co-Authored-By: Claude Opus 5.5 --- web/src/App.test.tsx | 17 + web/src/App.tsx | 2 + web/src/plan/PlanPage.module.css | 21 +- web/src/plan/PlanPage.test.tsx | 214 +++++++++- web/src/plan/PlanPage.tsx | 121 ++++-- web/src/plan/api.ts | 24 ++ .../settings/CalendarSettingsPage.module.css | 150 +++++++ .../settings/CalendarSettingsPage.test.tsx | 377 ++++++++++++++++++ web/src/settings/CalendarSettingsPage.tsx | 236 +++++++++++ web/src/settings/SettingsPage.test.tsx | 27 +- web/src/settings/SettingsPage.tsx | 11 +- web/src/settings/SettingsTabs.test.tsx | 6 +- web/src/settings/api.ts | 27 -- web/src/settings/calendarApi.ts | 165 ++++++++ web/src/test/ApiProviders.tsx | 16 +- 15 files changed, 1322 insertions(+), 92 deletions(-) create mode 100644 web/src/settings/CalendarSettingsPage.module.css create mode 100644 web/src/settings/CalendarSettingsPage.test.tsx create mode 100644 web/src/settings/CalendarSettingsPage.tsx create mode 100644 web/src/settings/calendarApi.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index abcaadf..a3dde74 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -36,6 +36,7 @@ const SCREENS = { '/settings/ai': true, '/settings/household': true, '/settings/regulars': true, + '/settings/calendar': true, } // SIGNED_IN answers what every route reads first: the session, then the @@ -353,4 +354,20 @@ describe('App offline bootstrap', () => { await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() }) + + it('/settings/calendar renders the Google Calendar screen inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + ...SIGNED_IN, + '/api/v1/integrations/google': () => + jsonResponse(200, { status: 'not_connected', calendar_id: null, calendar_name: null, connected_at: null, last_sync_error: null }), + }) + renderAt('/settings/calendar') + const heading = await screen.findByRole('heading', { level: 1, name: 'Google Calendar' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toHaveAttribute( + 'href', + '/api/v1/integrations/google/connect', + ) + }) }) diff --git a/web/src/App.tsx b/web/src/App.tsx index da3b58a..de51b4f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import { PlanPage } from './plan/PlanPage.tsx' import { OfflinePill } from './pwa/OfflinePill.tsx' import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' import { AISettingsPage } from './settings/AISettingsPage.tsx' +import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx' import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx' import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx' @@ -43,6 +44,7 @@ const screens: Partial> = { '/settings/ai': , '/settings/household': , '/settings/regulars': , + '/settings/calendar': , } // AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index 10af1ef..49acb3b 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -258,9 +258,9 @@ color: var(--color-neutral-700); } -/* An action with nothing behind it yet (Push to Calendar until Feature 16, - the shopping list until Feature 17): it keeps focus, so hovering, tapping - or focusing it shows the tooltip. */ +/* An action with nothing behind it yet (the shopping list until Feature + 17): it keeps focus, so hovering, tapping or focusing it shows the + tooltip. */ .unavailable { position: relative; display: flex; @@ -406,6 +406,21 @@ color: var(--color-accent-2-800); } +/* Under the Draft board's push button the line is centred and may wrap. */ +.mobileStatus { + flex: none; + justify-content: center; + white-space: normal; + text-align: center; +} + +/* A push failure's way to the Google Calendar settings. */ +.settingsLink { + color: inherit; + font-weight: 800; + text-decoration: underline; +} + .actions { display: flex; flex-wrap: wrap; diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 9e48c68..6f8584d 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -15,6 +15,7 @@ const THIS_WEEK = '/api/v1/plans/2026-10-05' const NEXT_WEEK = '/api/v1/plans/2026-10-12' const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28' const DRAFT = `${THIS_WEEK}/draft` +const PUSH = `${THIS_WEEK}/push-calendar` const JOB = '/api/v1/jobs/j1' const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11'] @@ -50,6 +51,21 @@ function planBody(status: string, names = NAMES) { } } +// syncedNow is the plan of the week of Oct 5 as a push answers it: synced +// at the tests' noon. +function syncedNow() { + return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' } +} + +// 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 } +} + // draftJob is job j1 drafting the week of Oct 5 into plan planId: a // redraft keeps the plan's id. function draftJob(status: string, error: string | null = null, planId = 'plan-1') { @@ -225,17 +241,155 @@ describe('PlanPage', () => { expect(screen.queryByText('Draft')).not.toBeInTheDocument() }) - it('offers Push to Calendar unavailable, with a tooltip saying why', async () => { - const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) + it('pushes the plan to Calendar, showing progress, then the plan as synced', async () => { + const answer = deferred() + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => answer.response, + }) await renderPage() - const push = await screen.findByRole('button', { name: 'Looks good — push to Calendar' }) - expect(push).toHaveAttribute('aria-disabled', 'true') - expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.') - expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Push to Calendar arrives once Google Calendar can be connected.') + expect(await screen.findByText('Not pushed to Google Calendar yet')).toBeInTheDocument() + const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) expect(declaredStyle(push).minHeight).toBe('50px') fireEvent.click(push) - expect(posts(fetchMock)).toEqual([]) + await waitFor(() => expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])) + const call = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST')! + expect(new Headers(call[1]?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken) + + // In progress: the button says so and is disabled, as is Rebuild week, + // and no day can be swapped. + const pushing = await screen.findByRole('button', { name: 'Pushing to Calendar…' }) + expect(pushing).toBeDisabled() + expect(pushing).toHaveAttribute('aria-busy', 'true') + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() + fireEvent.click(within(tile('MON')).getByRole('button')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + answer.send(jsonResponse(200, syncedNow())) + const status = await screen.findByText('Synced to Google Calendar just now') + expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') + expect(status.querySelector('svg')).not.toBeNull() + expect(screen.getByText('Synced')).toBeInTheDocument() + expect(screen.queryByText('Draft')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + // The plan shown is the one the push answered with: no second read. + expect(planReads(fetchMock)).toBe(1) + }) + + it('says a synced plan is on Google Calendar, and how long ago it was pushed', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')) }) + await renderPage() + + const status = await screen.findByText('Synced to Google Calendar 2 min ago') + expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') + expect(status.querySelector('svg')).not.toBeNull() + }) + + it('says changes to a pushed plan are not pushed, and pushes them', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), + [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow()), + }) + await renderPage() + + expect(await screen.findByText('Changes not pushed')).toBeInTheDocument() + const status = screen.getByText('Changes not pushed to Google Calendar') + expect(declaredStyle(status).color).toBe('var(--color-neutral-700)') + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(screen.queryByText('Changes not pushed')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + }) + + it.each([ + ['google_reconnect_required', 'Google access was revoked or expired.', 'Reconnect Google Calendar'], + ['no_calendar', 'No calendar is chosen for dinners yet.', 'Choose a calendar in Settings'], + ['not_connected', 'Google Calendar is not connected.', 'Connect Google Calendar'], + ['calendar_unavailable', 'The chosen calendar was deleted or MealQuest can no longer add dinners to it.', 'Choose another calendar'], + ])('sends a %s push failure to the Google Calendar settings', async (code, reason, fix) => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => errorResponse(409, code), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent(`Couldn't push to Calendar. ${reason} ${fix}`) + expect(within(alert).getByRole('link', { name: fix })).toHaveAttribute('href', '/settings/calendar') + expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + // The plan is as it was: not synced. + expect(screen.getByText('Draft')).toBeInTheDocument() + expect(screen.getByText('Not pushed to Google Calendar yet')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() + }) + + it('marks the Google Calendar connection to be read again when Google refused the grant', async () => { + const connection = { status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners', connected_at: null, last_sync_error: null } + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + const queryClient = createQueryClient() + queryClient.setQueryData(['integrations', 'google'], connection) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'), + }) + await api.loadMe() + render( + + + + + + + , + ) + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByRole('link', { name: 'Reconnect Google Calendar' })).toBeInTheDocument() + // The settings screen reads it again as it opens: it is now reconnect + // required. + expect(queryClient.getQueryState(['integrations', 'google'])?.isInvalidated).toBe(true) + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + }) + + it('says why any other push failed, and pushes again on Retry', async () => { + let push = () => errorResponse(502, 'google_failed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't push to Calendar. google_failed message") + expect(within(alert).queryByRole('link')).not.toBeInTheDocument() + + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + + it('reads the plan again when it changed while being pushed', async () => { + let plan = planBody('draft') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`POST ${PUSH}`]: () => errorResponse(409, 'plan_changed'), + }) + await renderPage() + + await screen.findByText('Beef tacos') + plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)]) + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByRole('alert')).toHaveTextContent( + "Couldn't push to Calendar. The plan changed while it was being pushed; push it again.", + ) + expect(await screen.findByText('Lasagna')).toBeInTheDocument() + expect(planReads(fetchMock)).toBe(2) }) it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => { @@ -715,20 +869,19 @@ 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 and Push to Calendar unavailable, saying - // why; Rebuild week outlined and working. + // 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.') const push = screen.getByRole('button', { name: 'Push to Calendar' }) - expect(push).toHaveAttribute('aria-disabled', 'true') - expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.') + expect(push).toBeEnabled() + expect(push).not.toHaveAttribute('aria-disabled') expect(declaredStyle(push).background).toBe('var(--color-accent)') const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) expect(rebuild).toBeEnabled() expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)') fireEvent.click(shopping) - fireEvent.click(push) expect(posts(fetchMock)).toEqual([]) // Mobile's own pieces are not shown. expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument() @@ -795,6 +948,43 @@ describe.each([ expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument() }) + it('pushes the plan from the header, showing progress, then the plan as synced', async () => { + const answer = deferred() + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), + [`POST ${PUSH}`]: () => answer.response, + ...lists, + }) + await renderPage() + + await screen.findByText('Beef tacos') + fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' })) + const pushing = await screen.findByRole('button', { name: 'Pushing…' }) + expect(pushing).toBeDisabled() + expect(pushing).toHaveAttribute('aria-busy', 'true') + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() + + answer.send(jsonResponse(200, syncedNow())) + const status = await screen.findByText('Synced to Google Calendar just now') + expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') + expect(screen.getByRole('button', { name: 'Push to Calendar' })).toBeEnabled() + }) + + it('links a google_reconnect_required push failure to the Google Calendar settings', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')), + [`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'), + ...lists, + }) + await renderPage() + + await screen.findByText('Beef tacos') + fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' })) + const alert = await screen.findByRole('alert') + expect(within(alert).getByRole('link', { name: 'Reconnect Google Calendar' })).toHaveAttribute('href', '/settings/calendar') + expect(screen.getByText('Synced to Google Calendar 2 min ago')).toBeInTheDocument() + }) + if (layout === 'desktop') { it('shows the Quest log and Regulars in the 290 px right panel', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 3867949..946ee3d 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -1,7 +1,8 @@ import { useQueryClient } from '@tanstack/react-query' import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react' import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' -import { useJob } from '../api/index.ts' +import { Link } from 'react-router' +import { ApiError, useJob } from '../api/index.ts' import { QUEST_LOG_KEY } from '../discover/api.ts' import { Confirm } from '../settings/LeaveGuard.tsx' import { methodLabels } from '../settings/RegularsSettingsPage.tsx' @@ -20,6 +21,7 @@ import { parseDraftResult, planKey, usePlan, + usePushToCalendar, useStartDraft, useSwapDay, type DraftResult, @@ -62,9 +64,20 @@ const draftFailures: Record = { internal_error: 'Something went wrong on the server.', } -// PUSH_HINT is why Push to Calendar is unavailable. -// TODO(Feature 16): connect Push to Calendar once Google Calendar is. -const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.' +// CALENDAR_SETTINGS is the Google Calendar settings screen. +const CALENDAR_SETTINGS = '/settings/calendar' + +// settingsFailures says why a push failed, by its API error code, for the +// failures only the Google Calendar settings can fix, and what to do there. +const settingsFailures: Record = { + google_reconnect_required: { reason: 'Google access was revoked or expired.', fix: 'Reconnect Google Calendar' }, + not_connected: { reason: 'Google Calendar is not connected.', fix: 'Connect Google Calendar' }, + no_calendar: { reason: 'No calendar is chosen for dinners yet.', fix: 'Choose a calendar in Settings' }, + calendar_unavailable: { + reason: 'The chosen calendar was deleted or MealQuest can no longer add dinners to it.', + fix: 'Choose another calendar', + }, +} // SHOPPING_HINT is why the shopping list is unavailable. // TODO(Feature 17): open the Supply run, with its count, once it is built. @@ -204,9 +217,13 @@ 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 and the -// unavailable Shopping list and Push to Calendar are actions in the header, +// 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. +// +// 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 +// Calendar settings when only they can fix it. function WeekBoard({ weekStart, timezone, @@ -225,6 +242,8 @@ function WeekBoard({ const job = useJob(DRAFT_JOB_KIND, jobId) const [confirming, setConfirming] = useState(false) const swap = useSwapDay(weekStart) + const push = usePushToCalendar(weekStart) + const pushing = push.isPending // The date of the day being swapped, while the swap sheet is open. const [swappingDate, setSwappingDate] = useState(null) const label = weekLabel(weekStart) @@ -282,13 +301,23 @@ function WeekBoard({ const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null) + // A push sends the plan shown: it waits for a draft or a swap of the week + // to settle, and neither starts while it runs. + const canPush = plan.isSuccess && plan.data !== null && !drafting && !swap.pending && !pushing + function pushToCalendar() { + if (!canPush) return + swap.dismiss() + push.mutate() + } + const { mutate } = startDraft function start(confirm: boolean) { setConfirming(false) - // A draft asked for again leaves the last one's failure, and a failed - // swap's, behind. + // A draft asked for again leaves the last one's failure, a failed + // swap's and a failed push's behind. setJobId(null) swap.dismiss() + push.reset() mutate( { weekStart, confirm }, { @@ -302,12 +331,14 @@ function WeekBoard({ // It waits for a swap of the week still pending, which may yet fail: the // Rebuild week tile is disabled meanwhile. function rebuild() { - if (swap.pending) return + if (swap.pending || pushing) return if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true) else start(false) } - const failure = swap.failed !== null ? ( + const failure = push.isError ? ( + + ) : swap.failed !== null ? ( swapIn(swap.failed!.swap)}> Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} @@ -328,6 +359,7 @@ function WeekBoard({ // swapIn sends the swap chosen in the sheet and closes it. function swapIn(chosen: Swap) { setSwappingDate(null) + push.reset() swap.mutate(chosen) } // dropIn swaps in a dinner dragged onto a day. A drag is not picked up, @@ -338,9 +370,10 @@ function WeekBoard({ } // 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. + // server answers it with must not overwrite another still pending. Nor is + // one sent while the week is being pushed to Calendar. function openSwap(date: string) { - if (swap.pending) return + if (swap.pending || pushing) return swap.dismiss() setSwappingDate(date) } @@ -379,7 +412,7 @@ function WeekBoard({ body = layout !== 'mobile' ? ( <> - +
    {days.map((day) => ( ))}
  • -
- - Looks good — push to Calendar - + + {sheet} ) @@ -458,14 +492,19 @@ function WeekBoard({ Shopping list - - + @@ -507,10 +546,11 @@ function WeekBoard({ ) } -// SyncStatus is the plan manager's status line: whether the plan is on -// Google Calendar and, once synced, how long ago it was pushed, kept current -// minute by minute. -function SyncStatus({ plan }: { plan: Plan }) { +// SyncStatus is the plan's status line, in the plan manager's header and +// under the Draft board's push button: whether the plan is on Google +// Calendar and, once synced, how long ago it was pushed, kept current minute +// by minute. +function SyncStatus({ plan, className }: { plan: Plan; className?: string }) { const { status, calendar_synced_at: syncedAt } = plan const [now, setNow] = useState(() => new Date()) useEffect(() => { @@ -518,7 +558,7 @@ function SyncStatus({ plan }: { plan: Plan }) { return () => clearInterval(timer) }, []) return ( -

+

{status === 'synced' && } {syncStatuses[status]} {status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`} @@ -526,9 +566,32 @@ function SyncStatus({ plan }: { plan: Plan }) { ) } -// Unavailable is an action with nothing behind it yet (Push to Calendar, the -// shopping list): it stays focusable, so a tap or focus shows the tooltip -// saying why, above it or, at the top of the page, below. +// PushFailure says why Push to Calendar failed. A failure only the Google +// Calendar settings can fix links there; any other offers to push again. +function PushFailure({ error, onRetry }: { error: Error; onRetry: () => void }) { + const code = error instanceof ApiError ? error.code : null + const settings = code !== null && Object.hasOwn(settingsFailures, code) ? settingsFailures[code] : undefined + if (settings !== undefined) { + return ( + + Couldn't push to Calendar. {settings.reason}{' '} + + {settings.fix} + + + ) + } + return ( + + Couldn't push to Calendar.{' '} + {code === 'plan_changed' ? 'The plan changed while it was being pushed; push it again.' : error.message} + + ) +} + +// Unavailable is an action with nothing behind it yet (the shopping list): +// it stays focusable, so a tap or focus shows the tooltip saying why, above +// it or, at the top of the page, below. function Unavailable({ hint, variant = 'primary', diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index bec55f3..6477089 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -3,6 +3,7 @@ import { ApiError, useApi, type ApiRead } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts' import { QUEST_LOG_KEY } from '../discover/api.ts' +import { noteGoogleFailure } from '../settings/calendarApi.ts' export const PLANS_PATH = '/api/v1/plans' @@ -274,3 +275,26 @@ export function useSwapDay(weekStart: string): SwapDay { dismiss, } } + +// usePushToCalendar pushes the plan of the week starting on weekStart to the +// chosen Google calendar (POST /api/v1/plans/{weekStart}/push-calendar). The +// server answers with the plan as synced, which replaces the one shown, as a +// swap's answer does. A push refused because the plan changed meanwhile +// reads the plan again; one Google refused the grant for reads the Google +// Calendar connection again, which is now reconnect required. +export function usePushToCalendar(weekStart: string) { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + const key = planKey(weekStart) + return useMutation({ + mutationFn: async (): Promise => { + const path = `${planPath(weekStart)}/push-calendar` + return parsePlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' }), false) + }, + onSuccess: (plan) => queryClient.setQueryData(key, plan), + onError: (error) => { + if (error instanceof ApiError && error.code === 'plan_changed') void queryClient.invalidateQueries({ queryKey: key }) + noteGoogleFailure(queryClient, error) + }, + }) +} diff --git a/web/src/settings/CalendarSettingsPage.module.css b/web/src/settings/CalendarSettingsPage.module.css new file mode 100644 index 0000000..ccfbc5f --- /dev/null +++ b/web/src/settings/CalendarSettingsPage.module.css @@ -0,0 +1,150 @@ +/* Settings · Google Calendar. */ +.page { + box-sizing: border-box; + min-height: 100%; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: center; + gap: 16px; +} + +.kicker { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent-700); +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; +} + +.shared { + margin: 0; + padding: 14px 16px; + border-radius: 26px; + background: var(--color-accent-2-100); + color: var(--color-accent-2-900); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; +} + +.loading { + height: 220px; + border-radius: 26px; +} + +.pickerLoading { + height: 72px; + border-radius: var(--radius-md); +} + +.card { + display: flex; + flex-direction: column; + gap: 14px; + border: 1px solid var(--color-neutral-300); +} + +.cardTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.text, +.hint, +.status, +.error, +.warning { + margin: 0; + font-family: var(--font-body); + line-height: 1.4; +} + +.text { + font-size: 15px; +} + +.hint, +.status { + font-size: 13px; + color: var(--color-neutral-700); +} + +.status:empty { + display: none; +} + +.error, +.warning { + font-size: 14px; + font-weight: 700; + color: var(--color-accent-700); +} + +/* "Connected · Dinners calendar": teal is the confirmation voice. */ +.connected { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; + color: var(--color-accent-2-800); +} + +/* A grant Google refused: the grape banner with Reconnect. */ +.reconnect { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px; + border-radius: 26px; + background: var(--color-accent-100); + border: 1px solid var(--color-accent-300); + color: var(--color-accent-800); +} + +.reconnectTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 20px; + font-weight: 400; +} + +.reconnectText { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 600; + line-height: 1.4; +} + +/* The connect endpoint is a page, so Connect and Reconnect are links drawn + as the primary button. */ +.connect { + align-self: flex-start; + text-decoration: none; +} + +.actions { + display: flex; + justify-content: flex-start; +} diff --git a/web/src/settings/CalendarSettingsPage.test.tsx b/web/src/settings/CalendarSettingsPage.test.tsx new file mode 100644 index 0000000..4baa7b1 --- /dev/null +++ b/web/src/settings/CalendarSettingsPage.test.tsx @@ -0,0 +1,377 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { Route, Routes, useLocation } from 'react-router' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { GOOGLE_NOT_CONNECTED } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { InDataRouter } from '../test/dataRouter.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { CalendarSettingsPage } from './CalendarSettingsPage.tsx' + +const GOOGLE = '/api/v1/integrations/google' +const CALENDARS = '/api/v1/integrations/google/calendars' +const CALENDAR = '/api/v1/integrations/google/calendar' + +// CONNECTED is a connection pushing to the household's Dinners calendar. +const CONNECTED = { + status: 'connected', + calendar_id: 'dinners@group.calendar.google.com', + calendar_name: 'Dinners', + connected_at: '2026-10-07T15:00:00Z', + last_sync_error: null, +} + +// RECONNECT is CONNECTED once Google has refused its grant. +const RECONNECT = { + ...CONNECTED, + status: 'reconnect_required', + last_sync_error: 'Google access was revoked or expired — reconnect', +} + +// CALENDAR_LIST is the account's writable calendars, in Google's order. +const CALENDAR_LIST = [ + { id: 'sam@example.com', summary: 'sam@example.com', primary: true, accessRole: 'owner' }, + { id: 'dinners@group.calendar.google.com', summary: 'Dinners', primary: false, accessRole: 'owner' }, + { id: 'family@group.calendar.google.com', summary: 'Family', primary: false, accessRole: 'writer' }, +] + +type Responder = (body: unknown) => Response | Promise + +// stubAPI answers "METHOD path" from responders, each called with the +// request's decoded JSON body. Any other request fails the test. +function stubAPI(responders: Record): Mock { + const all: Record = { 'GET /api/v1/me': () => jsonResponse(200, ME), ...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}`) + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined + return Promise.resolve(respond(body)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// sent lists the requests other than GETs, as "METHOD path" and body. +function sent(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => (init?.method ?? 'GET') !== 'GET') + .map(([input, init]) => ({ + request: `${init?.method} ${String(input)}`, + body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined, + })) +} + +// reads counts the GETs of path. +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 } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function CurrentPath() { + return

{useLocation().pathname}

+} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the page at /settings/calendar in a data router. +async function renderPage() { + const api = createApiClient(fakeLocation('/settings/calendar'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient = createQueryClient() + render( + + + + + } /> + } /> + + + + , + ) + return queryClient +} + +describe('CalendarSettingsPage', () => { + it('shows the screen title and goes back to Settings', async () => { + stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED) }) + await renderPage() + + expect(screen.getByRole('heading', { level: 1, name: 'Google Calendar' })).toBeInTheDocument() + expect(screen.getByText('Settings')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + expect(await screen.findByTestId('path')).toHaveTextContent('/settings') + }) + + it('shows "Connect Google Calendar" when not connected, opening the connect endpoint as a page', async () => { + const fetchMock = stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED) }) + await renderPage() + + const connect = await screen.findByRole('link', { name: 'Connect Google Calendar' }) + expect(connect).toHaveAttribute('href', '/api/v1/integrations/google/connect') + expect(declaredStyle(connect).background).toBe('var(--color-accent)') + expect(screen.getByRole('region', { name: 'Not connected' })).toBeInTheDocument() + // Nothing to pick yet, nor to disconnect, and Google is not asked. + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument() + expect(reads(fetchMock, CALENDARS)).toBe(0) + }) + + it('reports a status that could not load, and reads it again on Retry', async () => { + let status = () => errorResponse(500, 'internal') + stubAPI({ [`GET ${GOOGLE}`]: () => status() }) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('Google Calendar status could not load: internal message') + status = () => jsonResponse(200, GOOGLE_NOT_CONNECTED) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument() + }) + + it('refuses a status it does not know', async () => { + stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'paused' }) }) + await renderPage() + + expect(await screen.findByRole('alert')).toHaveTextContent('response is not a Google Calendar connection') + }) + + it('shows "Connected · {calendar}" in teal-800 and the writable calendars to pick from', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + }) + await renderPage() + + const connected = await screen.findByText('Connected · Dinners') + expect(declaredStyle(connected).color).toBe('var(--color-accent-2-800)') + expect(connected.querySelector('svg')).not.toBeNull() + const picker = await screen.findByRole('combobox', { name: 'Calendar' }) + expect(picker).toHaveValue('dinners@group.calendar.google.com') + expect(within(picker).getAllByRole('option').map((option) => option.textContent)).toEqual([ + 'sam@example.com (primary)', + 'Dinners', + 'Family', + ]) + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /Connect|Reconnect/ })).not.toBeInTheDocument() + }) + + it('chooses another calendar at once, showing it saving, and reads the plans again', async () => { + const answer = deferred() + const fetchMock = stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + [`PUT ${CALENDAR}`]: () => answer.response, + }) + const queryClient = await renderPage() + queryClient.setQueryData(['plans', '2026-10-05'], null) + + const picker = await screen.findByRole('combobox', { name: 'Calendar' }) + fireEvent.change(picker, { target: { value: 'family@group.calendar.google.com' } }) + await waitFor(() => + expect(sent(fetchMock)).toEqual([{ request: `PUT ${CALENDAR}`, body: { calendar_id: 'family@group.calendar.google.com' } }]), + ) + expect(await screen.findByText('Saving…')).toBeInTheDocument() + expect(picker).toBeDisabled() + expect(picker).toHaveValue('family@group.calendar.google.com') + + answer.send(jsonResponse(200, { calendar_id: 'family@group.calendar.google.com', calendar_name: 'Family' })) + expect(await screen.findByText('Connected · Family')).toBeInTheDocument() + expect(picker).toBeEnabled() + expect(picker).toHaveValue('family@group.calendar.google.com') + expect(screen.queryByText('Saving…')).not.toBeInTheDocument() + // Choosing another calendar makes every pushed plan changed. + expect(queryClient.getQueryState(['plans', '2026-10-05'])?.isInvalidated).toBe(true) + }) + + it('says a choice that was not saved, keeping the calendar chosen before', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + [`PUT ${CALENDAR}`]: () => errorResponse(422, 'unknown_calendar'), + }) + await renderPage() + + const picker = await screen.findByRole('combobox', { name: 'Calendar' }) + fireEvent.change(picker, { target: { value: 'family@group.calendar.google.com' } }) + expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: unknown_calendar message') + expect(picker).toHaveValue('dinners@group.calendar.google.com') + expect(screen.getByText('Connected · Dinners')).toBeInTheDocument() + }) + + it('asks to choose a calendar when connected with none chosen', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, { ...CONNECTED, calendar_id: null, calendar_name: null }), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + }) + await renderPage() + + expect(await screen.findByText('Connected')).toBeInTheDocument() + expect(await screen.findByText('Choose the calendar to push dinners to.')).toBeInTheDocument() + const picker = screen.getByRole('combobox', { name: 'Calendar' }) + expect(picker).toHaveValue('') + expect(within(picker).getByRole('option', { name: 'Choose a calendar' })).toBeDisabled() + }) + + it('says the chosen calendar can no longer be pushed to once it drops out of the list', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST.filter((calendar) => calendar.summary !== 'Dinners')), + }) + await renderPage() + + expect( + await screen.findByText('Dinners was deleted or MealQuest can no longer add dinners to it. Choose another calendar.'), + ).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Calendar' })).toHaveValue('') + }) + + it('says when the account has no calendar MealQuest can add dinners to', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, []), + }) + await renderPage() + + expect(await screen.findByText('This Google account has no calendar MealQuest can add dinners to.')).toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + }) + + it('reports calendars that could not load, and lists them again on Retry', async () => { + let list = () => errorResponse(502, 'google_failed') + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => list(), + }) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('Your calendars could not load: google_failed message') + list = () => jsonResponse(200, CALENDAR_LIST) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('combobox', { name: 'Calendar' })).toHaveValue('dinners@group.calendar.google.com') + }) + + it('turns to the reconnect banner when Google refuses the grant while listing calendars', async () => { + let status: unknown = CONNECTED + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, status), + [`GET ${CALENDARS}`]: () => { + status = RECONNECT + return errorResponse(409, 'google_reconnect_required') + }, + }) + await renderPage() + + expect(await screen.findByRole('region', { name: 'Reconnect Google Calendar' })).toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + expect(screen.queryByText('Connected · Dinners')).not.toBeInTheDocument() + }) + + it('shows a grape banner with Reconnect when reconnect is required, and offers nothing that needs Google', async () => { + const fetchMock = stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, RECONNECT) }) + await renderPage() + + const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' }) + expect(declaredStyle(banner).background).toBe('var(--color-accent-100)') + expect(declaredStyle(banner).color).toBe('var(--color-accent-800)') + expect(banner).toHaveTextContent('Google access was revoked or expired — reconnect') + expect(banner).toHaveTextContent('Nothing can be pushed to Calendar until you connect again.') + expect(within(banner).getByRole('link', { name: 'Reconnect' })).toHaveAttribute( + 'href', + '/api/v1/integrations/google/connect', + ) + // The chosen calendar is kept, but not picked from, nor called connected. + expect(screen.getByText('Dinners go to Dinners once Google Calendar is reconnected.')).toBeInTheDocument() + expect(screen.queryByText(/^Connected/)).not.toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + expect(reads(fetchMock, CALENDARS)).toBe(0) + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument() + }) + + it('disconnects only once confirmed, then shows Connect Google Calendar', async () => { + let status: unknown = CONNECTED + const answer = deferred() + const fetchMock = stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, status), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + [`DELETE ${GOOGLE}`]: () => answer.response, + }) + const queryClient = await renderPage() + queryClient.setQueryData(['plans', '2026-10-05'], null) + + fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' })) + let dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' }) + expect(dialog).toHaveTextContent('Dinners already pushed to Dinners stay on the calendar.') + fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(sent(fetchMock)).toEqual([]) + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' })) + await waitFor(() => expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }])) + // While it disconnects the sheet stays, and cannot be dismissed. + expect(await within(dialog).findByRole('button', { name: 'Disconnecting…' })).toBeDisabled() + expect(within(dialog).getByRole('button', { name: 'Keep connected' })).toBeDisabled() + + status = GOOGLE_NOT_CONNECTED + answer.send(new Response(null, { status: 204 })) + expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument() + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + // Disconnecting forgets every pushed event, so pushed plans are changed. + expect(queryClient.getQueryState(['plans', '2026-10-05'])?.isInvalidated).toBe(true) + }) + + it('says a disconnect Google refused, keeping the connection', async () => { + stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED), + [`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST), + [`DELETE ${GOOGLE}`]: () => errorResponse(502, 'revoke_failed'), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' })) + const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' })) + expect(await within(dialog).findByRole('alert')).toHaveTextContent('Not disconnected: revoke_failed message') + fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.getByText('Connected · Dinners')).toBeInTheDocument() + }) + + it('disconnects a connection that needs reconnecting', async () => { + let status: unknown = RECONNECT + const fetchMock = stubAPI({ + [`GET ${GOOGLE}`]: () => jsonResponse(200, status), + [`DELETE ${GOOGLE}`]: () => { + status = GOOGLE_NOT_CONNECTED + return new Response(null, { status: 204 }) + }, + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' })) + const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' })) + expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }]) + }) +}) diff --git a/web/src/settings/CalendarSettingsPage.tsx b/web/src/settings/CalendarSettingsPage.tsx new file mode 100644 index 0000000..40af793 --- /dev/null +++ b/web/src/settings/CalendarSettingsPage.tsx @@ -0,0 +1,236 @@ +import { CalendarDays, Check, ChevronLeft, RefreshCw, type LucideIcon } from 'lucide-react' +import { useId, useState, type ReactNode } from 'react' +import { useNavigate } from 'react-router' +import { Button } from '../ui/Button.tsx' +import buttonStyles from '../ui/Button.module.css' +import { Card } from '../ui/Card.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Select } from '../ui/Select.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { + GOOGLE_CONNECT_PATH, + useChooseGoogleCalendar, + useDisconnectGoogle, + useGoogleCalendars, + useGoogleConnection, + type GoogleConnection, +} from './calendarApi.ts' +import styles from './CalendarSettingsPage.module.css' +import { Confirm } from './LeaveGuard.tsx' + +// Settings · Google Calendar: connect the household's Google account, choose +// the calendar Push to Calendar writes the week's dinners to, and +// disconnect. A grant Google has refused shows a grape banner asking to +// reconnect, and nothing that needs Google is offered until then. +export function CalendarSettingsPage() { + const connection = useGoogleConnection() + return ( +
+
+

+ Push to Calendar adds one event per dinner. It is one-way: edits made in Google Calendar do not come back. +

+ {connection.isPending && } + {connection.isError && ( + void connection.refetch()}> + Google Calendar status could not load: {connection.error.message} + + )} + {connection.isSuccess && } +
+ ) +} + +function Header() { + const navigate = useNavigate() + return ( +
+ void navigate('/settings')}> + + +
+

Settings

+

Google Calendar

+
+
+ ) +} + +function Connection({ connection }: { connection: GoogleConnection }) { + switch (connection.status) { + case 'not_connected': + return ( +
+

Connect the household's Google account to push each week's plan to one of its calendars.

+ Connect Google Calendar +
+ ) + case 'reconnect_required': + return ( + <> + +
+

+ {connection.calendar_name === null + ? 'No calendar chosen. Choose one once Google Calendar is reconnected.' + : `Dinners go to ${connection.calendar_name} once Google Calendar is reconnected.`} +

+ +
+ + ) + case 'connected': + return ( +
+

+ + {connection.calendar_name === null ? 'Connected' : `Connected · ${connection.calendar_name}`} +

+ + +
+ ) + } +} + +// ReconnectBanner is the grape banner of a grant Google refused, with why +// and Reconnect. +function ReconnectBanner({ error }: { error: string | null }) { + const titleId = useId() + return ( +
+

+ Reconnect Google Calendar +

+ {error !== null &&

{error}

} +

Nothing can be pushed to Calendar until you connect again.

+ Reconnect +
+ ) +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + const titleId = useId() + return ( + +

+ {title} +

+ {children} +
+ ) +} + +// ConnectLink opens the connect endpoint as a page: the server sends the +// browser on to Google's consent screen, and back here once Google answers. +function ConnectLink({ icon = CalendarDays, children }: { icon?: LucideIcon; children: ReactNode }) { + return ( + + + {children} + + ) +} + +// CalendarPicker chooses, from the calendars the account can write to, the +// one dinners are pushed to; a choice is saved at once. A calendar chosen +// before that has since been deleted or become read-only is no longer +// listed, and is said to be so. +function CalendarPicker({ connection }: { connection: GoogleConnection }) { + const calendars = useGoogleCalendars(true) + const choose = useChooseGoogleCalendar() + + if (calendars.isPending) return + if (calendars.isError) { + return ( + void calendars.refetch()}> + Your calendars could not load: {calendars.error.message} + + ) + } + if (calendars.data.length === 0) { + return

This Google account has no calendar MealQuest can add dinners to.

+ } + + const listed = calendars.data.some((calendar) => calendar.id === connection.calendar_id) + const value = choose.isPending ? choose.variables : listed ? connection.calendar_id! : '' + return ( + <> + {connection.calendar_id === null &&

Choose the calendar to push dinners to.

} + {connection.calendar_id !== null && !listed && ( +

+ {connection.calendar_name} was deleted or MealQuest can no longer add dinners to it. Choose another calendar. +

+ )} + +

+ {choose.isPending && 'Saving…'} +

+ {choose.isError && ( +

+ Not saved: {choose.error.message} +

+ )} +

+ After choosing another calendar, the next push writes the whole week there. Dinners already pushed stay where they + are. +

+ + ) +} + +// Disconnect revokes MealQuest's access at Google, after asking. +function Disconnect({ calendar }: { calendar: string | null }) { + const [confirming, setConfirming] = useState(false) + const disconnect = useDisconnectGoogle() + return ( + <> +
+ +
+ setConfirming(false)} + onConfirm={() => disconnect.mutate(undefined, { onSuccess: () => setConfirming(false) })} + > + MealQuest gives up its access to your Google account and stops pushing dinners.{' '} + {calendar === null ? 'Dinners already pushed' : `Dinners already pushed to ${calendar}`} stay on the calendar. + + + ) +} diff --git a/web/src/settings/SettingsPage.test.tsx b/web/src/settings/SettingsPage.test.tsx index afb28dd..a039276 100644 --- a/web/src/settings/SettingsPage.test.tsx +++ b/web/src/settings/SettingsPage.test.tsx @@ -4,7 +4,7 @@ import { Route, Routes, useLocation } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx' +import { AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' @@ -22,7 +22,7 @@ afterEach(() => { }) const REGULARS_PATH = '/api/v1/dinners?status=regular' -const CALENDAR_PATH = '/api/v1/settings/calendar' +const CALENDAR_PATH = '/api/v1/integrations/google' const NOTIFICATIONS_PATH = '/api/v1/settings/notifications' // stubSettings answers as today's server does (see SETTINGS_API), with @@ -96,9 +96,9 @@ describe('SettingsPage', () => { expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument() expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([ REGULARS_PATH, + CALENDAR_PATH, '/api/v1/settings/ai', '/api/v1/settings/ai/models', - CALENDAR_PATH, '/api/v1/settings/household', NOTIFICATIONS_PATH, '/api/v1/settings/recipe-search', @@ -109,7 +109,6 @@ describe('SettingsPage', () => { stubSettings() renderSettings() expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument() - expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument() expect( await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'), ).toBeInTheDocument() @@ -119,7 +118,8 @@ describe('SettingsPage', () => { it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => { stubSettings({ [REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }), - [CALENDAR_PATH]: () => jsonResponse(200, { connected: true, calendar_name: 'Dinners calendar' }), + [CALENDAR_PATH]: () => + jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners calendar' }), [NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }), }) renderSettings() @@ -133,7 +133,7 @@ describe('SettingsPage', () => { it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => { stubSettings({ [REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }), - [CALENDAR_PATH]: () => jsonResponse(200, { connected: false }), + [CALENDAR_PATH]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED), [NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }), }) renderSettings() @@ -144,6 +144,15 @@ describe('SettingsPage', () => { ).toBeInTheDocument() }) + it.each([ + ['connected with no calendar chosen', { status: 'connected' }, 'Connected · no calendar chosen'], + ['reconnect required', { status: 'reconnect_required', calendar_id: 'c1', calendar_name: 'Dinners' }, 'Reconnect needed'], + ])('says Google Calendar is %s', async (_, connection, said) => { + stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, ...connection }) }) + renderSettings() + expect(await within(row('Google Calendar')).findByText(said)).toBeInTheDocument() + }) + it('reports a status endpoint failure other than a missing endpoint', async () => { stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') }) renderSettings() @@ -151,10 +160,10 @@ describe('SettingsPage', () => { expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar') }) - it('refuses a connected calendar without a name', async () => { - stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { connected: true }) }) + it('refuses a Google Calendar status it does not know', async () => { + stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'paused' }) }) renderSettings() - expect(await screen.findByRole('alert')).toHaveTextContent('response is not a calendar status') + expect(await screen.findByRole('alert')).toHaveTextContent('response is not a Google Calendar connection') }) it('shows another provider and model as the settings name them', async () => { diff --git a/web/src/settings/SettingsPage.tsx b/web/src/settings/SettingsPage.tsx index e96881e..425623a 100644 --- a/web/src/settings/SettingsPage.tsx +++ b/web/src/settings/SettingsPage.tsx @@ -11,16 +11,15 @@ import { Tag } from '../ui/Tag.tsx' import { useBreakpoint } from '../shell/useBreakpoint.ts' import { aiStatus, - calendarStatus, formatClockTime, notificationsStatus, regularDinnersStatus, useAISettings, - useCalendarStatus, useHouseholdSettings, useNotificationsStatus, useRegularDinnersCount, } from './api.ts' +import { calendarStatus, useGoogleConnection } from './calendarApi.ts' import styles from './SettingsPage.module.css' import { SettingsTabs } from './SettingsTabs.tsx' @@ -39,14 +38,14 @@ export function SettingsPage() { } // The mobile settings list (handoff mock 1p). Each row opens its settings -// page and shows a live status line. Regular dinners, Google Calendar and -// Notifications & Google Chat read endpoints their features have not served -// yet; until then each shows its not-yet-available state (see api.ts). +// page and shows a live status line. Regular dinners and Notifications & +// Google Chat read endpoints their features have not served yet; until then +// each shows its not-yet-available state (see api.ts). function SettingsList() { const household = useHouseholdSettings() const ai = useAISettings() const regulars = useRegularDinnersCount() - const calendar = useCalendarStatus() + const calendar = useGoogleConnection() const notifications = useNotificationsStatus() const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError) diff --git a/web/src/settings/SettingsTabs.test.tsx b/web/src/settings/SettingsTabs.test.tsx index 87a4983..f3cdc36 100644 --- a/web/src/settings/SettingsTabs.test.tsx +++ b/web/src/settings/SettingsTabs.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx' +import { AI_MODELS, AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' @@ -154,7 +154,7 @@ describe('SettingsPage across the 768 px breakpoint', () => { it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => { stubAPI({ 'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), - 'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'), + 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), 'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'), }) const resize = await renderAt(767) @@ -169,7 +169,7 @@ describe('SettingsPage across the 768 px breakpoint', () => { it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => { stubAPI({ 'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), - 'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'), + 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), 'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'), }) const resize = await renderAt(1180) diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index 058caf0..bab2bcc 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -14,9 +14,6 @@ export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test' // dinners read API. Until it lands the server answers 404 and the Regular // dinners row says the count is unavailable. export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular' -// TODO(Feature 16): the Google Calendar integration status. Until Feature 16 -// serves it the server answers 404, which is the truth: not connected. -export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar' // TODO(notifications feature): the Web Push and Google Chat status. Until it // is served the server answers 404, which is the truth: push is off and no // Google Chat space is connected. @@ -516,30 +513,6 @@ export function regularDinnersStatus(count: number | null): string { return `${count} on rotation` } -// CalendarStatus is the Google Calendar integration status; calendar_name -// is the connected calendar's name. -export type CalendarStatus = { connected: false } | { connected: true; calendar_name: string } - -export function useCalendarStatus() { - const { apiFetch } = useApi() - return useQuery({ - queryKey: ['settings', 'calendar'], - queryFn: () => - fetchUnlessAbsent(apiFetch, CALENDAR_STATUS_PATH, (body): CalendarStatus => { - if (isRecord(body) && body.connected === false) return { connected: false } - if (isRecord(body) && body.connected === true && typeof body.calendar_name === 'string') { - return { connected: true, calendar_name: body.calendar_name } - } - throw new Error(`GET ${CALENDAR_STATUS_PATH}: response is not a calendar status`) - }), - }) -} - -export function calendarStatus(status: CalendarStatus | null): string { - if (status === null || !status.connected) return 'Not connected' - return `Connected · ${status.calendar_name}` -} - // NotificationsStatus is whether Web Push is on and the connected Google // Chat space's name, null when none is. export type NotificationsStatus = { push: boolean; chat_space: string | null } diff --git a/web/src/settings/calendarApi.ts b/web/src/settings/calendarApi.ts new file mode 100644 index 0000000..c706e54 --- /dev/null +++ b/web/src/settings/calendarApi.ts @@ -0,0 +1,165 @@ +import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' +import { ApiError, useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' + +export const GOOGLE_PATH = '/api/v1/integrations/google' +// GOOGLE_CONNECT_PATH starts connecting: the browser opens it as a page, +// and the server sends it on to Google's consent screen and, once Google +// answers, back to /settings/calendar. +export const GOOGLE_CONNECT_PATH = `${GOOGLE_PATH}/connect` +export const GOOGLE_CALENDARS_PATH = `${GOOGLE_PATH}/calendars` +export const GOOGLE_CALENDAR_PATH = `${GOOGLE_PATH}/calendar` + +// GOOGLE_RECONNECT_REQUIRED is the API error code of a call Google refused +// the stored grant for: only connecting again helps. +export const GOOGLE_RECONNECT_REQUIRED = 'google_reconnect_required' + +export const GOOGLE_STATUSES = ['not_connected', 'connected', 'reconnect_required'] as const + +// GoogleStatus is reconnect_required once Google has refused the stored +// grant: the connection and its chosen calendar are kept, but nothing can be +// pushed until the household connects again. +export type GoogleStatus = (typeof GOOGLE_STATUSES)[number] + +// GoogleConnection is GET /api/v1/integrations/google: the connection's +// status, the calendar chosen to push dinners to, when it was connected and +// why it needs a reconnect, each null when there is none. +export type GoogleConnection = { + status: GoogleStatus + calendar_id: string | null + calendar_name: string | null + connected_at: string | null + last_sync_error: string | null +} + +// GoogleCalendar is a calendar MealQuest can push dinners to: one the +// connected account can write to. +export type GoogleCalendar = { id: string; summary: string; primary: boolean } + +export const GOOGLE_KEY = ['integrations', 'google'] as const +const GOOGLE_CALENDARS_KEY = ['integrations', 'google', 'calendars'] as const + +function isStringOrNull(value: unknown): value is string | null { + return value === null || typeof value === 'string' +} + +function parseConnection(where: string, body: unknown): GoogleConnection { + if ( + !isRecord(body) || + !GOOGLE_STATUSES.includes(body.status as GoogleStatus) || + !isStringOrNull(body.calendar_id) || + !isStringOrNull(body.calendar_name) || + !isStringOrNull(body.connected_at) || + !isStringOrNull(body.last_sync_error) + ) { + throw new Error(`${where}: response is not a Google Calendar connection`) + } + return { + status: body.status as GoogleStatus, + calendar_id: body.calendar_id, + calendar_name: body.calendar_name, + connected_at: body.connected_at, + last_sync_error: body.last_sync_error, + } +} + +// reconnectRequired says whether error is Google refusing the stored grant. +export function reconnectRequired(error: Error): boolean { + return error instanceof ApiError && error.code === GOOGLE_RECONNECT_REQUIRED +} + +// noteGoogleFailure reads the connection again after a call Google refused +// the grant for: the server now reports it as reconnect_required. +export function noteGoogleFailure(queryClient: QueryClient, error: Error) { + if (reconnectRequired(error)) void queryClient.invalidateQueries({ queryKey: GOOGLE_KEY, exact: true }) +} + +// useGoogleConnection is the Google Calendar connection. +export function useGoogleConnection() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: GOOGLE_KEY, + queryFn: async () => parseConnection(`GET ${GOOGLE_PATH}`, await apiFetch(GOOGLE_PATH)), + }) +} + +// useGoogleCalendars lists the connected account's calendars MealQuest can +// push to, in Google's order. It asks Google, so it is read only while +// connected. +export function useGoogleCalendars(enabled: boolean) { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useQuery({ + queryKey: GOOGLE_CALENDARS_KEY, + enabled, + queryFn: async (): Promise => { + let body: unknown + try { + body = await apiFetch(GOOGLE_CALENDARS_PATH) + } catch (error) { + noteGoogleFailure(queryClient, error as Error) + throw error + } + if ( + !Array.isArray(body) || + !body.every((c) => isRecord(c) && typeof c.id === 'string' && typeof c.summary === 'string' && typeof c.primary === 'boolean') + ) { + throw new Error(`GET ${GOOGLE_CALENDARS_PATH}: response is not a calendar list`) + } + return body.map((c: Record) => ({ id: c.id as string, summary: c.summary as string, primary: c.primary as boolean })) + }, + }) +} + +// useChooseGoogleCalendar chooses the calendar dinners are pushed to. +// Choosing another calendar than the one pushed to makes every pushed plan +// changed (the next push writes the whole week to the new calendar), so the +// plans are read again. +export function useChooseGoogleCalendar() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (calendarId: string) => { + const where = `PUT ${GOOGLE_CALENDAR_PATH}` + const body = await apiFetch(GOOGLE_CALENDAR_PATH, { method: 'PUT', body: { calendar_id: calendarId } }) + if (!isRecord(body) || body.calendar_id !== calendarId || typeof body.calendar_name !== 'string') { + throw new Error(`${where}: response is not the calendar chosen`) + } + return { calendar_id: calendarId, calendar_name: body.calendar_name } + }, + onSuccess: (chosen) => { + queryClient.setQueryData(GOOGLE_KEY, (connection) => connection && { ...connection, ...chosen }) + void queryClient.invalidateQueries({ queryKey: ['plans'] }) + }, + onError: (error) => noteGoogleFailure(queryClient, error), + }) +} + +// useDisconnectGoogle revokes MealQuest's access at Google and forgets the +// connection and every pushed event, making each pushed plan changed: the +// connection and the plans are read again. +export function useDisconnectGoogle() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => apiFetch(GOOGLE_PATH, { method: 'DELETE' }), + onSuccess: () => { + queryClient.removeQueries({ queryKey: GOOGLE_CALENDARS_KEY }) + void queryClient.invalidateQueries({ queryKey: GOOGLE_KEY, exact: true }) + void queryClient.invalidateQueries({ queryKey: ['plans'] }) + }, + }) +} + +// calendarStatus is the settings list's line for the connection: the +// handoff's "Connected · Dinners calendar". +export function calendarStatus(connection: GoogleConnection): string { + switch (connection.status) { + case 'not_connected': + return 'Not connected' + case 'reconnect_required': + return 'Reconnect needed' + case 'connected': + return connection.calendar_name === null ? 'Connected · no calendar chosen' : `Connected · ${connection.calendar_name}` + } +} diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index 60c786f..3a377a6 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -51,13 +51,23 @@ export const RECIPE_SEARCH_SETTINGS = { updated_at: '2026-10-01T15:00:00.000Z', } +// GOOGLE_NOT_CONNECTED is a GET /api/v1/integrations/google body: nothing +// connected. +export const GOOGLE_NOT_CONNECTED = { + status: 'not_connected', + calendar_id: null, + calendar_name: null, + connected_at: null, + last_sync_error: null, +} + // SETTINGS_API answers the settings home's requests, for stubFetchByPath, as -// today's server does: the dinners, Google Calendar and notifications status -// endpoints do not exist yet. The home's sub-tabs stay mounted on mobile, so +// today's server does: the dinners and notifications status endpoints do not +// exist yet, and Google Calendar is not connected. The home's sub-tabs stay mounted on mobile, so // it also reads the AI models and recipe search settings. export const SETTINGS_API = { '/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), - '/api/v1/settings/calendar': () => errorResponse(404, 'not_found'), + '/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), '/api/v1/settings/notifications': () => errorResponse(404, 'not_found'), '/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), -- 2.55.0 From cc8a08aadc4fec3181b62b9418ee796f1f1daae2 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 01:03:40 -0400 Subject: [PATCH 2/6] Hold a plan_changed push until the changed plan is shown; announce reconnect A push refused with plan_changed now stays pending until the plan has been read again, so Push and Retry stay disabled until the changed plan is on screen and nothing unreviewed can be pushed. A test holds that read back and checks that no second push is sent meanwhile, and that Retry pushes once it lands. The reconnect banner's reason and required action are now an alert, as ErrorBanner's message is, so a calendar request that turns the screen to reconnect required is announced. Task: 677261bc-1a7c-4d96-b8f9-7601caf2186d Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.test.tsx | 35 +++++++++++++++++++ web/src/plan/api.ts | 10 +++--- .../settings/CalendarSettingsPage.module.css | 6 ++++ .../settings/CalendarSettingsPage.test.tsx | 9 ++++- web/src/settings/CalendarSettingsPage.tsx | 9 +++-- 5 files changed, 61 insertions(+), 8 deletions(-) diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 6f8584d..3f2904e 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -392,6 +392,41 @@ describe('PlanPage', () => { expect(planReads(fetchMock)).toBe(2) }) + it('pushes nothing again until the changed plan is read and shown', async () => { + let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) + let push = () => errorResponse(409, 'plan_changed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => read(), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + await screen.findByText('Beef tacos') + const reread = deferred() + read = () => reread.response + const pushButton = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) + fireEvent.click(pushButton) + await waitFor(() => expect(planReads(fetchMock)).toBe(2)) + // While the changed plan is read, the push stays busy: neither Push + // nor Retry sends it again. + expect(screen.getByRole('button', { name: 'Pushing to Calendar…' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Pushing to Calendar…' })) + expect(screen.getByText('Beef tacos')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + + reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)]))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.") + expect(screen.getByText('Lasagna')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() + + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => { let plan = planBody('draft') let job = draftJob('running') diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 6477089..491efda 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -280,8 +280,10 @@ export function useSwapDay(weekStart: string): SwapDay { // chosen Google calendar (POST /api/v1/plans/{weekStart}/push-calendar). The // server answers with the plan as synced, which replaces the one shown, as a // swap's answer does. A push refused because the plan changed meanwhile -// reads the plan again; one Google refused the grant for reads the Google -// Calendar connection again, which is now reconnect required. +// reads the plan again, and stays pending until that read lands: nothing is +// pushed again before the changed plan is shown. One Google refused the +// grant for reads the Google Calendar connection again, which is now +// reconnect required. export function usePushToCalendar(weekStart: string) { const { apiFetch } = useApi() const queryClient = useQueryClient() @@ -292,9 +294,9 @@ export function usePushToCalendar(weekStart: string) { return parsePlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' }), false) }, onSuccess: (plan) => queryClient.setQueryData(key, plan), - onError: (error) => { - if (error instanceof ApiError && error.code === 'plan_changed') void queryClient.invalidateQueries({ queryKey: key }) + onError: async (error) => { noteGoogleFailure(queryClient, error) + if (error instanceof ApiError && error.code === 'plan_changed') await queryClient.invalidateQueries({ queryKey: key }) }, }) } diff --git a/web/src/settings/CalendarSettingsPage.module.css b/web/src/settings/CalendarSettingsPage.module.css index ccfbc5f..3c6f7bf 100644 --- a/web/src/settings/CalendarSettingsPage.module.css +++ b/web/src/settings/CalendarSettingsPage.module.css @@ -129,6 +129,12 @@ font-weight: 400; } +.reconnectStatus { + display: flex; + flex-direction: column; + gap: 10px; +} + .reconnectText { margin: 0; font-family: var(--font-body); diff --git a/web/src/settings/CalendarSettingsPage.test.tsx b/web/src/settings/CalendarSettingsPage.test.tsx index 4baa7b1..6803202 100644 --- a/web/src/settings/CalendarSettingsPage.test.tsx +++ b/web/src/settings/CalendarSettingsPage.test.tsx @@ -279,7 +279,14 @@ describe('CalendarSettingsPage', () => { }) await renderPage() - expect(await screen.findByRole('region', { name: 'Reconnect Google Calendar' })).toBeInTheDocument() + const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' }) + // Why and what to do are announced as the screen turns to the banner. + const alert = screen.getByRole('alert') + expect(banner).toContainElement(alert) + expect(alert).toHaveTextContent( + 'Google access was revoked or expired — reconnect' + 'Nothing can be pushed to Calendar until you connect again.', + ) + expect(within(alert).queryByRole('link')).not.toBeInTheDocument() expect(screen.queryByRole('combobox')).not.toBeInTheDocument() expect(screen.queryByText('Connected · Dinners')).not.toBeInTheDocument() }) diff --git a/web/src/settings/CalendarSettingsPage.tsx b/web/src/settings/CalendarSettingsPage.tsx index 40af793..ffb78b0 100644 --- a/web/src/settings/CalendarSettingsPage.tsx +++ b/web/src/settings/CalendarSettingsPage.tsx @@ -96,7 +96,8 @@ function Connection({ connection }: { connection: GoogleConnection }) { } // ReconnectBanner is the grape banner of a grant Google refused, with why -// and Reconnect. +// and Reconnect. Why and what to do are an alert, as ErrorBanner's message +// is: a calendar request that meets the refusal turns the screen to it. function ReconnectBanner({ error }: { error: string | null }) { const titleId = useId() return ( @@ -104,8 +105,10 @@ function ReconnectBanner({ error }: { error: string | null }) {

Reconnect Google Calendar

- {error !== null &&

{error}

} -

Nothing can be pushed to Calendar until you connect again.

+
+ {error !== null &&

{error}

} +

Nothing can be pushed to Calendar until you connect again.

+
Reconnect ) -- 2.55.0 From f77960ad05b9401ac2e2ba990b414f3730c80dc4 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 01:23:01 -0400 Subject: [PATCH 3/6] Keep a week's pending push across leaving the week and coming back A push to Calendar now lives in the query client's mutation cache, keyed by week, as a swap does, instead of in the Draft board's own useMutation. A board remounted by leaving the week and coming back therefore still sees a push pending, including one held open by the plan_changed reread, and keeps Push and Retry disabled until that read lands. The reread now refetches the week's plan even while it is not shown, so a return finds the changed plan in the cache rather than the stale one. Tests leave the week and come back with the reread held open (no second POST; the changed plan, then Retry, push once), and let the reread land while the week is left (the board returns on the changed plan). Task: 677261bc-1a7c-4d96-b8f9-7601caf2186d Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.test.tsx | 71 +++++++++++++++++++++++++++++ web/src/plan/PlanPage.tsx | 8 ++-- web/src/plan/api.ts | 83 ++++++++++++++++++++++++++++------ 3 files changed, 144 insertions(+), 18 deletions(-) diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 3f2904e..a63e447 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -427,6 +427,77 @@ describe('PlanPage', () => { expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) }) + it('pushes nothing again until the changed plan is shown, even after leaving the week and coming back', async () => { + let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) + let push = () => errorResponse(409, 'plan_changed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => read(), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + await screen.findByText('Beef tacos') + const reread = deferred() + read = () => reread.response + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + await waitFor(() => expect(planReads(fetchMock)).toBe(2)) + + // Leaving the week and coming back while the changed plan is read + // remounts the board: the push is still busy, and sends nothing again. + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + await screen.findByText('Beef tacos') + expect(screen.getByRole('button', { name: 'Pushing to Calendar…' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Pushing to Calendar…' })) + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + + await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.") + expect(screen.getByText('Lasagna')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() + + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + + it('shows the changed plan read while the week was left, and pushes it', async () => { + let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) + let push = () => errorResponse(409, 'plan_changed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => read(), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + await screen.findByText('Beef tacos') + const reread = deferred() + read = () => reread.response + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + await waitFor(() => expect(planReads(fetchMock)).toBe(2)) + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])))) + read = () => jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])) + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + + // The board comes back on the changed plan: Push sends that one. + expect(await screen.findByText('Lasagna')).toBeInTheDocument() + expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('The plan changed while it was being pushed; push it again.') + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => { let plan = planBody('draft') let job = draftJob('running') diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 946ee3d..62e42cc 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -243,7 +243,7 @@ function WeekBoard({ const [confirming, setConfirming] = useState(false) const swap = useSwapDay(weekStart) const push = usePushToCalendar(weekStart) - const pushing = push.isPending + const pushing = push.pending // The date of the day being swapped, while the swap sheet is open. const [swappingDate, setSwappingDate] = useState(null) const label = weekLabel(weekStart) @@ -302,11 +302,13 @@ function WeekBoard({ const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null) // A push sends the plan shown: it waits for a draft or a swap of the week - // to settle, and neither starts while it runs. + // to settle, and neither starts while it runs, even across leaving the + // week and coming back. const canPush = plan.isSuccess && plan.data !== null && !drafting && !swap.pending && !pushing function pushToCalendar() { if (!canPush) return swap.dismiss() + push.reset() push.mutate() } @@ -336,7 +338,7 @@ function WeekBoard({ else start(false) } - const failure = push.isError ? ( + const failure = push.error !== null ? ( ) : swap.failed !== null ? ( swapIn(swap.failed!.swap)}> diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 491efda..fd580f4 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -276,27 +276,80 @@ export function useSwapDay(weekStart: string): SwapDay { } } +// pushKey keys the pushes of the week starting on weekStart in the mutation +// cache, where they outlive the Draft board that sent them. +function pushKey(weekStart: string) { + return ['plans', weekStart, 'push'] as const +} + +// PushToCalendar is the push of one week, as the mutation cache holds it: +// whether it is pending, and why the latest failed, if it failed. +export type PushToCalendar = { + pending: boolean + error: Error | null + mutate: () => void + reset: () => void +} + // usePushToCalendar pushes the plan of the week starting on weekStart to the // chosen Google calendar (POST /api/v1/plans/{weekStart}/push-calendar). The // server answers with the plan as synced, which replaces the one shown, as a // swap's answer does. A push refused because the plan changed meanwhile -// reads the plan again, and stays pending until that read lands: nothing is -// pushed again before the changed plan is shown. One Google refused the -// grant for reads the Google Calendar connection again, which is now -// reconnect required. -export function usePushToCalendar(weekStart: string) { +// reads the plan again, whether or not the week is shown, and stays pending +// until that read lands: nothing is pushed again before the changed plan is +// the one shown. One Google refused the grant for reads the Google Calendar +// connection again, which is now reconnect required. +// +// The week's pushes live in the mutation cache, not in the component, as its +// swaps do, so leaving the week and coming back keeps one pending. reset +// leaves settled pushes behind; it is refused while one is pending. +export function usePushToCalendar(weekStart: string): PushToCalendar { const { apiFetch } = useApi() const queryClient = useQueryClient() const key = planKey(weekStart) - return useMutation({ - mutationFn: async (): Promise => { - const path = `${planPath(weekStart)}/push-calendar` - return parsePlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' }), false) - }, - onSuccess: (plan) => queryClient.setQueryData(key, plan), - onError: async (error) => { - noteGoogleFailure(queryClient, error) - if (error instanceof ApiError && error.code === 'plan_changed') await queryClient.invalidateQueries({ queryKey: key }) - }, + const mutationKey = pushKey(weekStart) + const mutations = queryClient.getMutationCache() + // The week's pushes, in the order sent. + const pushes = useMutationState({ + filters: { mutationKey }, + select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }), }) + const latest = pushes.at(-1) + + function mutate() { + const mutation = mutations.build(queryClient, { + mutationKey, + // Kept until reset: a push pending in a week left must still be found + // when it is shown again. + gcTime: Infinity, + mutationFn: async () => { + const path = `${planPath(weekStart)}/push-calendar` + return parsePlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' }), false) + }, + onSuccess: (plan) => queryClient.setQueryData(key, plan), + onError: async (error) => { + noteGoogleFailure(queryClient, error) + if (error instanceof ApiError && error.code === 'plan_changed') { + await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }) + } + }, + }) + // A failure is the mutation's state, which error reports. + mutation.execute().catch(() => undefined) + } + + function reset() { + const week = mutations.findAll({ mutationKey }) + if (week.some((mutation) => mutation.state.status === 'pending')) { + throw new Error(`The week of ${weekStart} has a push pending, which cannot be reset.`) + } + for (const mutation of week) mutations.remove(mutation) + } + + return { + pending: pushes.some((push) => push.status === 'pending'), + error: latest?.status === 'error' ? latest.error! : null, + mutate, + reset, + } } -- 2.55.0 From 8a02e04110b149ffff7eb2e912c63a1b3e459ab4 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 01:45:33 -0400 Subject: [PATCH 4/6] Keep Push closed over a cached plan; bound kept week mutations A plan_changed reread answered from the service worker's cache left the old plan shown with Push and Retry open. Push now needs a plan read from the server (Plan.cached, set from cacheFallbackHeader), and the push failure's Retry reads the plan again while the plan shown is cached. Settled swaps and pushes were kept for every week ever touched. When a week is shown, the settled ones of every other week with none pending are now removed; pending ones still survive leaving the week and coming back. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.test.tsx | 51 +++++++++++++++++++++++++ web/src/plan/PlanPage.tsx | 21 ++++++++--- web/src/plan/api.test.tsx | 69 +++++++++++++++++++++++++++++++++- web/src/plan/api.ts | 42 ++++++++++++++++++--- 4 files changed, 171 insertions(+), 12 deletions(-) diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index a63e447..f3d73bf 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -467,6 +467,57 @@ describe('PlanPage', () => { expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) }) + it("pushes nothing again while the changed plan's read is answered from the cache", async () => { + let read = () => jsonResponse(200, planBody('draft')) + let push = () => errorResponse(409, 'plan_changed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => read(), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + await screen.findByText('Beef tacos') + // The server can't be reached: the service worker answers the read of + // the changed plan with the plan it cached before. + read = () => { + const response = jsonResponse(200, planBody('draft')) + response.headers.set(cacheFallbackHeader, '1') + return response + } + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + const cachedCopy = "The plan changed while it was being pushed. The plan shown came from this device's cache, not the server; read it again before pushing." + const failed = await screen.findByRole('alert') + expect(planReads(fetchMock)).toBe(2) + expect(screen.getByText('Beef tacos')).toBeInTheDocument() + const pushButton = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) + expect(pushButton).toBeDisabled() + fireEvent.click(pushButton) + expect(failed).toHaveTextContent(cachedCopy) + + // Retry reads the plan again rather than pushing: still the cache, so + // nothing is pushed. + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(planReads(fetchMock)).toBe(3)) + expect(await screen.findByRole('alert')).toHaveTextContent(cachedCopy) + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeDisabled() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + + // The server is back: Retry shows the changed plan, and only then may it + // be pushed. + read = () => jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])) + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Lasagna')).toBeInTheDocument() + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.") + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + it('shows the changed plan read while the week was left, and pushes it', async () => { let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) let push = () => errorResponse(409, 'plan_changed') diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 62e42cc..4cf8667 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -303,8 +303,11 @@ function WeekBoard({ // A push sends the plan shown: it waits for a draft or a swap of the week // to settle, and neither starts while it runs, even across leaving the - // week and coming back. - const canPush = plan.isSuccess && plan.data !== null && !drafting && !swap.pending && !pushing + // week and coming back. Nor does it send a plan from this device's cache, + // which may not be the server's: after a push refused because the plan + // changed, the read of the changed plan may be answered from the cache. + const cached = plan.isSuccess && plan.data !== null && plan.data.cached + const canPush = plan.isSuccess && plan.data !== null && !cached && !drafting && !swap.pending && !pushing function pushToCalendar() { if (!canPush) return swap.dismiss() @@ -339,7 +342,7 @@ function WeekBoard({ } const failure = push.error !== null ? ( - + void plan.refetch() : pushToCalendar} /> ) : swap.failed !== null ? ( swapIn(swap.failed!.swap)}> Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} @@ -569,8 +572,10 @@ function SyncStatus({ plan, className }: { plan: Plan; className?: string }) { } // PushFailure says why Push to Calendar failed. A failure only the Google -// Calendar settings can fix links there; any other offers to push again. -function PushFailure({ error, onRetry }: { error: Error; onRetry: () => void }) { +// Calendar settings can fix links there; any other offers to push again, +// or, while the plan shown is this device's cached copy, to read it again +// from the server first. +function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean; onRetry: () => void }) { const code = error instanceof ApiError ? error.code : null const settings = code !== null && Object.hasOwn(settingsFailures, code) ? settingsFailures[code] : undefined if (settings !== undefined) { @@ -586,7 +591,11 @@ function PushFailure({ error, onRetry }: { error: Error; onRetry: () => void }) return ( Couldn't push to Calendar.{' '} - {code === 'plan_changed' ? 'The plan changed while it was being pushed; push it again.' : error.message} + {cached + ? `${code === 'plan_changed' ? 'The plan changed while it was being pushed.' : error.message} The plan shown came from this device's cache, not the server; read it again before pushing.` + : code === 'plan_changed' + ? 'The plan changed while it was being pushed; push it again.' + : error.message} ) } diff --git a/web/src/plan/api.test.tsx b/web/src/plan/api.test.tsx index 438c2de..138fe65 100644 --- a/web/src/plan/api.test.tsx +++ b/web/src/plan/api.test.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' -import { planKey, usePlan, useSwapDay, type Plan } from './api.ts' +import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan } from './api.ts' const WEEK = '2026-10-05' const PLAN = `/api/v1/plans/${WEEK}` @@ -203,3 +203,70 @@ describe('useSwapDay', () => { await act(async () => friday.send(jsonResponse(200, planBody()))) }) }) + +describe('week mutations', () => { + const NEXT = '2026-10-12' + const LATER = '2026-10-19' + + // weekMutations lists the swaps and pushes the mutation cache holds for + // the week starting on weekStart, with their status. + function weekMutations(queryClient: QueryClient, weekStart: string) { + return queryClient + .getMutationCache() + .findAll({ mutationKey: ['plans', weekStart] }) + .map((mutation) => `${String(mutation.options.mutationKey![2])} ${mutation.state.status}`) + } + + it('keeps settled swaps and pushes while their week is shown, and leaves them behind once it is not, unless one is pending', async () => { + const nextPush = deferred() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`PUT ${FRIDAY}`]: () => jsonResponse(200, planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })), + [`POST ${PLAN}/push-calendar`]: () => errorResponse(500, 'internal'), + [`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'), + [`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply, + [`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'), + }) + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient = createQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const hook = renderHook( + ({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week), push: usePushToCalendar(week) }), + { wrapper, initialProps: { week: WEEK } }, + ) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + + // A swap and a push of the week settle: both stay while it is shown, + // the push's failure speaking for the week. + act(() => hook.result.current.swap.mutate(ORZO)) + await waitFor(() => expect(hook.result.current.swap.pending).toBe(false)) + act(() => hook.result.current.push.mutate()) + await waitFor(() => expect(hook.result.current.push.error).not.toBeNull()) + expect(weekMutations(queryClient, WEEK)).toEqual(['swap success', 'push error']) + + // Another week shown leaves them behind; a push sent there stays + // pending across showing a third week. + hook.rerender({ week: NEXT }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + expect(weekMutations(queryClient, WEEK)).toEqual([]) + act(() => hook.result.current.push.mutate()) + hook.rerender({ week: LATER }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + expect(weekMutations(queryClient, NEXT)).toEqual(['push pending']) + hook.rerender({ week: NEXT }) + expect(hook.result.current.push.pending).toBe(true) + + // Once it settles, it stays while its week is shown and is left behind + // once another is. + await act(async () => nextPush.send(errorResponse(500, 'internal'))) + await waitFor(() => expect(hook.result.current.push.error).not.toBeNull()) + expect(weekMutations(queryClient, NEXT)).toEqual(['push error']) + hook.rerender({ week: LATER }) + expect(weekMutations(queryClient, NEXT)).toEqual([]) + }) +}) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index fd580f4..4a058e0 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiError, useApi, type ApiRead } from '../api/index.ts' import { isRecord } from '../api/errors.ts' @@ -177,6 +178,30 @@ function sameDay(a: PlanDay, b: PlanDay): boolean { ) } +// WeekMutation is a kind of mutation of a week the mutation cache keeps for +// it: its swaps and its pushes. +type WeekMutation = 'swap' | 'push' + +// useWeekMutationsKept bounds the mutations of kind the cache keeps. When a +// week is shown, the settled ones of every other week are removed, unless +// that week still has one pending: a pending one, and those an earlier +// pending one must see, stay across leaving its week and coming back. The +// cache holds at most the week shown's and those of weeks left pending. +function useWeekMutationsKept(weekStart: string, kind: WeekMutation) { + const queryClient = useQueryClient() + useEffect(() => { + const mutations = queryClient.getMutationCache() + const left = mutations.findAll({ + predicate: ({ options: { mutationKey: key } }) => + key !== undefined && key.length === 3 && key[0] === 'plans' && key[1] !== weekStart && key[2] === kind, + }) + const pending = new Set(left.filter((m) => m.state.status === 'pending').map((m) => m.options.mutationKey![1])) + for (const mutation of left) { + if (!pending.has(mutation.options.mutationKey![1])) mutations.remove(mutation) + } + }, [queryClient, weekStart, kind]) +} + // swapKey keys the swaps of the week starting on weekStart in the mutation // cache, where they outlive the Draft board that sent them. function swapKey(weekStart: string) { @@ -206,10 +231,12 @@ export type SwapDay = { // earlier one is answered with is not written over a later one, and its // failure is not the week's. dismiss leaves settled swaps behind; it is // refused while one is pending, so a later swap is always in the cache for -// an earlier, pending one to see. +// an earlier, pending one to see. Settled swaps of a week no longer shown +// are left behind too (useWeekMutationsKept). export function useSwapDay(weekStart: string): SwapDay { const { apiFetch } = useApi() const queryClient = useQueryClient() + useWeekMutationsKept(weekStart, 'swap') const key = planKey(weekStart) const mutationKey = swapKey(weekStart) const mutations = queryClient.getMutationCache() @@ -225,7 +252,8 @@ export function useSwapDay(weekStart: string): SwapDay { const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId) const mutation = mutations.build(queryClient, { mutationKey, - // Kept until dismissed: a later swap must stay in the cache while an + // Kept until dismissed or its week is left settled + // (useWeekMutationsKept): a later swap must stay in the cache while an // earlier one is pending. gcTime: Infinity, mutationFn: async ({ date, dinner }) => { @@ -302,10 +330,13 @@ export type PushToCalendar = { // // The week's pushes live in the mutation cache, not in the component, as its // swaps do, so leaving the week and coming back keeps one pending. reset -// leaves settled pushes behind; it is refused while one is pending. +// leaves settled pushes behind; it is refused while one is pending. Settled +// pushes of a week no longer shown are left behind too +// (useWeekMutationsKept). export function usePushToCalendar(weekStart: string): PushToCalendar { const { apiFetch } = useApi() const queryClient = useQueryClient() + useWeekMutationsKept(weekStart, 'push') const key = planKey(weekStart) const mutationKey = pushKey(weekStart) const mutations = queryClient.getMutationCache() @@ -319,8 +350,9 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { function mutate() { const mutation = mutations.build(queryClient, { mutationKey, - // Kept until reset: a push pending in a week left must still be found - // when it is shown again. + // Kept until reset or its week is left settled (useWeekMutationsKept): + // a push pending in a week left must still be found when it is shown + // again. gcTime: Infinity, mutationFn: async () => { const path = `${planPath(weekStart)}/push-calendar` -- 2.55.0 From 5c8484cfb89d41a325b807f44edc6158760ae1a8 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 02:07:05 -0400 Subject: [PATCH 5/6] Collect settled week mutations when they settle, not only on a week change The shared cleanup of swap and push mutations also runs whenever one of its kind settles, so an entry that settles while another week is shown is no longer kept indefinitely under gcTime: Infinity. Pending mutations, and the settled ones of a week that still has one pending, are never removed; a left week's latest mutation, when it failed, stays until another week is shown so coming straight back still shows the failure. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/api.test.tsx | 67 +++++++++++++++++++++++++++++++++++++++ web/src/plan/api.ts | 36 ++++++++++++++------- 2 files changed, 92 insertions(+), 11 deletions(-) diff --git a/web/src/plan/api.test.tsx b/web/src/plan/api.test.tsx index 138fe65..94eb6fb 100644 --- a/web/src/plan/api.test.tsx +++ b/web/src/plan/api.test.tsx @@ -269,4 +269,71 @@ describe('week mutations', () => { hook.rerender({ week: LATER }) expect(weekMutations(queryClient, NEXT)).toEqual([]) }) + + it('leaves behind what settles in a week no longer shown, unless that week still has one pending or it is the failure that speaks for it', async () => { + const friday = deferred() + const monday = deferred() + const nextPush = deferred() + const laterPush = deferred() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`PUT ${FRIDAY}`]: () => friday.reply, + [`PUT ${MONDAY}`]: () => monday.reply, + [`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'), + [`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply, + [`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'), + [`POST /api/v1/plans/${LATER}/push-calendar`]: () => laterPush.reply, + }) + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient = createQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const hook = renderHook( + ({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week), push: usePushToCalendar(week) }), + { wrapper, initialProps: { week: WEEK } }, + ) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + + // Two swaps of the week and a push of the next are left pending, and a + // third week is shown. + act(() => hook.result.current.swap.mutate(ORZO)) + act(() => hook.result.current.swap.mutate(FISH)) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap pending'])) + hook.rerender({ week: NEXT }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + act(() => hook.result.current.push.mutate()) + hook.rerender({ week: LATER }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + expect(weekMutations(queryClient, NEXT)).toEqual(['push pending']) + + // The later swap settles first: the earlier, still pending, must see it, + // so both stay. + await act(async () => monday.send(jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])) + + // The push succeeds while the third week is shown: it is left behind, + // and the week's pending swap is not. + await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT }))) + await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([])) + expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success']) + + // Once the earlier swap fails, the week has none pending: both are left + // behind, the failure not being the week's latest. + await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable'))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([])) + + // A push of the third week fails once another is shown: it stays, to be + // shown on coming back, until yet another week is. + act(() => hook.result.current.push.mutate()) + hook.rerender({ week: NEXT }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + await act(async () => laterPush.send(errorResponse(500, 'internal'))) + await waitFor(() => expect(weekMutations(queryClient, LATER)).toEqual(['push error'])) + hook.rerender({ week: WEEK }) + expect(weekMutations(queryClient, LATER)).toEqual([]) + }) }) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 4a058e0..027bed1 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -185,20 +185,34 @@ type WeekMutation = 'swap' | 'push' // useWeekMutationsKept bounds the mutations of kind the cache keeps. When a // week is shown, the settled ones of every other week are removed, unless // that week still has one pending: a pending one, and those an earlier -// pending one must see, stay across leaving its week and coming back. The -// cache holds at most the week shown's and those of weeks left pending. +// pending one must see, stay across leaving its week and coming back. When +// one settles, the same is done, except that a week's latest, when it +// failed, stays until another week is shown, so that coming straight back to +// it shows the failure. The cache holds at most the week shown's, those of +// weeks left pending, and one failure of each week whose last pending one +// settled since the week shown was. function useWeekMutationsKept(weekStart: string, kind: WeekMutation) { const queryClient = useQueryClient() useEffect(() => { const mutations = queryClient.getMutationCache() - const left = mutations.findAll({ - predicate: ({ options: { mutationKey: key } }) => - key !== undefined && key.length === 3 && key[0] === 'plans' && key[1] !== weekStart && key[2] === kind, - }) - const pending = new Set(left.filter((m) => m.state.status === 'pending').map((m) => m.options.mutationKey![1])) - for (const mutation of left) { - if (!pending.has(mutation.options.mutationKey![1])) mutations.remove(mutation) + const isKind = (key: readonly unknown[] | undefined) => key !== undefined && key.length === 3 && key[0] === 'plans' && key[2] === kind + function sweep(settled: boolean) { + const left = mutations.findAll({ + predicate: ({ options: { mutationKey: key } }) => isKind(key) && key![1] !== weekStart, + }) + const pending = new Set(left.filter((m) => m.state.status === 'pending').map((m) => m.options.mutationKey![1])) + const latest = new Map(left.map((m) => [m.options.mutationKey![1], m])) + for (const mutation of left) { + const week = mutation.options.mutationKey![1] + if (pending.has(week)) continue + if (settled && latest.get(week) === mutation && mutation.state.status === 'error') continue + mutations.remove(mutation) + } } + sweep(false) + return mutations.subscribe((event) => { + if (event.type === 'updated' && isKind(event.mutation.options.mutationKey) && event.mutation.state.status !== 'pending') sweep(true) + }) }, [queryClient, weekStart, kind]) } @@ -252,7 +266,7 @@ export function useSwapDay(weekStart: string): SwapDay { const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId) const mutation = mutations.build(queryClient, { mutationKey, - // Kept until dismissed or its week is left settled + // Kept until dismissed or settled in a week not shown // (useWeekMutationsKept): a later swap must stay in the cache while an // earlier one is pending. gcTime: Infinity, @@ -350,7 +364,7 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { function mutate() { const mutation = mutations.build(queryClient, { mutationKey, - // Kept until reset or its week is left settled (useWeekMutationsKept): + // Kept until reset or settled in a week not shown (useWeekMutationsKept): // a push pending in a week left must still be found when it is shown // again. gcTime: Infinity, -- 2.55.0 From 3a1884de38ffa612efdb11ccccae1df71f38350a Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 02:28:01 -0400 Subject: [PATCH 6/6] Keep week mutations bounded for the query client's lifetime, not Plan's The cleanup of settled swaps and pushes was subscribed only while a Draft board was mounted, so one that settled after the household left Plan stayed in the mutation cache with gcTime Infinity. keepWeekMutations is now installed once on the query client's own mutation cache and never removed; the hooks only mark which week is shown. Pending ones, and those an earlier pending one needs, are still kept. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/api.test.tsx | 66 +++++++++++++++++++++- web/src/plan/api.ts | 114 +++++++++++++++++++++++++------------- 2 files changed, 140 insertions(+), 40 deletions(-) diff --git a/web/src/plan/api.test.tsx b/web/src/plan/api.test.tsx index 94eb6fb..6571f62 100644 --- a/web/src/plan/api.test.tsx +++ b/web/src/plan/api.test.tsx @@ -1,11 +1,12 @@ import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, render, renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' +import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' -import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan } from './api.ts' +import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushToCalendar, type SwapDay } from './api.ts' const WEEK = '2026-10-05' const PLAN = `/api/v1/plans/${WEEK}` @@ -336,4 +337,65 @@ describe('week mutations', () => { hook.rerender({ week: WEEK }) expect(weekMutations(queryClient, LATER)).toEqual([]) }) + + it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => { + const friday = deferred() + const monday = deferred() + const push = deferred() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`PUT ${FRIDAY}`]: () => friday.reply, + [`PUT ${MONDAY}`]: () => monday.reply, + [`POST ${PLAN}/push-calendar`]: () => push.reply, + }) + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient = createQueryClient() + let week!: { plan: ReturnType; swap: SwapDay; push: PushToCalendar } + let navigate!: NavigateFunction + function Week() { + week = { plan: usePlan(WEEK), swap: useSwapDay(WEEK), push: usePushToCalendar(WEEK) } + return null + } + function Navigator() { + navigate = useNavigate() + return null + } + render( + + + + + + } /> + + + + + , + ) + await waitFor(() => expect(week.plan.isSuccess).toBe(true)) + + // Two swaps and a push of the week are left pending, and Settings is + // shown. + act(() => week.swap.mutate(ORZO)) + act(() => week.swap.mutate(FISH)) + act(() => week.push.mutate()) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap pending', 'push pending'])) + act(() => void navigate('/settings')) + + // The swaps settle: once neither is pending they are left behind, and + // the push still pending is not. + await act(async () => monday.send(jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success', 'push pending'])) + await act(async () => friday.send(jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['push pending'])) + + // The push succeeds: the plan as synced is recorded, and the push is + // left behind. + const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' } + await act(async () => push.send(jsonResponse(200, synced))) + await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([])) + expect(queryClient.getQueryData(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') }) + }) }) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 027bed1..0a8ee4a 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useMutationState, useQuery, useQueryClient, type Mutation, type QueryClient } from '@tanstack/react-query' import { ApiError, useApi, type ApiRead } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts' @@ -178,42 +178,80 @@ function sameDay(a: PlanDay, b: PlanDay): boolean { ) } -// WeekMutation is a kind of mutation of a week the mutation cache keeps for -// it: its swaps and its pushes. -type WeekMutation = 'swap' | 'push' +// weekMutation is the kind and week of a swap or push of a week the mutation +// cache keeps for it, or null for any other mutation. +function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null { + const key = mutation.options.mutationKey + if (key === undefined || key.length !== 3 || key[0] !== 'plans' || (key[2] !== 'swap' && key[2] !== 'push')) return null + return { kind: key[2], week: String(key[1]) } +} -// useWeekMutationsKept bounds the mutations of kind the cache keeps. When a -// week is shown, the settled ones of every other week are removed, unless -// that week still has one pending: a pending one, and those an earlier -// pending one must see, stay across leaving its week and coming back. When -// one settles, the same is done, except that a week's latest, when it -// failed, stays until another week is shown, so that coming straight back to -// it shows the failure. The cache holds at most the week shown's, those of -// weeks left pending, and one failure of each week whose last pending one -// settled since the week shown was. -function useWeekMutationsKept(weekStart: string, kind: WeekMutation) { - const queryClient = useQueryClient() - useEffect(() => { - const mutations = queryClient.getMutationCache() - const isKind = (key: readonly unknown[] | undefined) => key !== undefined && key.length === 3 && key[0] === 'plans' && key[2] === kind - function sweep(settled: boolean) { - const left = mutations.findAll({ - predicate: ({ options: { mutationKey: key } }) => isKind(key) && key![1] !== weekStart, - }) - const pending = new Set(left.filter((m) => m.state.status === 'pending').map((m) => m.options.mutationKey![1])) - const latest = new Map(left.map((m) => [m.options.mutationKey![1], m])) - for (const mutation of left) { - const week = mutation.options.mutationKey![1] - if (pending.has(week)) continue - if (settled && latest.get(week) === mutation && mutation.state.status === 'error') continue +// WeeksShown is what keepWeekMutations is told of the weeks shown: show +// marks a week shown until the function it returns is called. +type WeeksShown = { show: (weekStart: string) => () => void } + +const weeksShown = new WeakMap() + +// keepWeekMutations bounds the swaps and pushes the mutation cache of +// queryClient keeps, for as long as the query client lives, whatever is +// shown: it is installed on the client's own mutation cache once, the first +// time a week is shown, and is never removed, so a swap or push that settles +// after its Draft board, or the whole Plan, is gone is still collected. +// +// When a week is shown, the settled ones of every other week are removed, +// unless that week still has one pending: a pending one, and those an +// earlier pending one must see, stay across leaving its week and coming +// back. When one settles, the same is done, except that a week's latest, +// when it failed, stays until a week is next shown, so that coming straight +// back to it shows the failure. The cache holds at most the weeks shown's, +// those of weeks left pending, and one failure of each week whose last +// pending one settled since a week was last shown. +function keepWeekMutations(queryClient: QueryClient): WeeksShown { + const kept = weeksShown.get(queryClient) + if (kept !== undefined) return kept + const mutations = queryClient.getMutationCache() + // How many times each week is shown at once. + const shown = new Map() + function sweep(settled: boolean) { + // The mutations of each kind of each week not shown, in the order sent. + const left = new Map() + for (const mutation of mutations.getAll()) { + const of = weekMutation(mutation) + if (of === null || shown.has(of.week)) continue + const id = `${of.kind} ${of.week}` + left.set(id, [...(left.get(id) ?? []), mutation]) + } + for (const week of left.values()) { + if (week.some((mutation) => mutation.state.status === 'pending')) continue + const latest = week.at(-1) + for (const mutation of week) { + if (settled && mutation === latest && mutation.state.status === 'error') continue mutations.remove(mutation) } } + } + mutations.subscribe((event) => { + if (event.type === 'updated' && weekMutation(event.mutation) !== null && event.mutation.state.status !== 'pending') sweep(true) + }) + const show = (weekStart: string) => { + shown.set(weekStart, (shown.get(weekStart) ?? 0) + 1) sweep(false) - return mutations.subscribe((event) => { - if (event.type === 'updated' && isKind(event.mutation.options.mutationKey) && event.mutation.state.status !== 'pending') sweep(true) - }) - }, [queryClient, weekStart, kind]) + return () => { + const times = shown.get(weekStart)! - 1 + if (times === 0) shown.delete(weekStart) + else shown.set(weekStart, times) + } + } + const keeping = { show } + weeksShown.set(queryClient, keeping) + return keeping +} + +// useWeekShown marks the week starting on weekStart shown while the +// component is mounted (keepWeekMutations). +function useWeekShown(weekStart: string) { + const queryClient = useQueryClient() + useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart]) } // swapKey keys the swaps of the week starting on weekStart in the mutation @@ -246,11 +284,11 @@ export type SwapDay = { // failure is not the week's. dismiss leaves settled swaps behind; it is // refused while one is pending, so a later swap is always in the cache for // an earlier, pending one to see. Settled swaps of a week no longer shown -// are left behind too (useWeekMutationsKept). +// are left behind too (keepWeekMutations). export function useSwapDay(weekStart: string): SwapDay { const { apiFetch } = useApi() const queryClient = useQueryClient() - useWeekMutationsKept(weekStart, 'swap') + useWeekShown(weekStart) const key = planKey(weekStart) const mutationKey = swapKey(weekStart) const mutations = queryClient.getMutationCache() @@ -267,7 +305,7 @@ export function useSwapDay(weekStart: string): SwapDay { const mutation = mutations.build(queryClient, { mutationKey, // Kept until dismissed or settled in a week not shown - // (useWeekMutationsKept): a later swap must stay in the cache while an + // (keepWeekMutations): a later swap must stay in the cache while an // earlier one is pending. gcTime: Infinity, mutationFn: async ({ date, dinner }) => { @@ -346,11 +384,11 @@ export type PushToCalendar = { // swaps do, so leaving the week and coming back keeps one pending. reset // leaves settled pushes behind; it is refused while one is pending. Settled // pushes of a week no longer shown are left behind too -// (useWeekMutationsKept). +// (keepWeekMutations). export function usePushToCalendar(weekStart: string): PushToCalendar { const { apiFetch } = useApi() const queryClient = useQueryClient() - useWeekMutationsKept(weekStart, 'push') + useWeekShown(weekStart) const key = planKey(weekStart) const mutationKey = pushKey(weekStart) const mutations = queryClient.getMutationCache() @@ -364,7 +402,7 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { function mutate() { const mutation = mutations.build(queryClient, { mutationKey, - // Kept until reset or settled in a week not shown (useWeekMutationsKept): + // Kept until reset or settled in a week not shown (keepWeekMutations): // a push pending in a week left must still be found when it is shown // again. gcTime: Infinity, -- 2.55.0