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..f3d73bf 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,312 @@ 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('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('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("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') + 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 () => { @@ -715,20 +1026,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 +1105,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..4cf8667 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.pending // 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,28 @@ 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, even across leaving the + // 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() + push.reset() + 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 +336,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.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} @@ -328,6 +364,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 +375,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 +417,7 @@ function WeekBoard({ body = layout !== 'mobile' ? ( <> - +
    {days.map((day) => ( ))}
  • -
- - Looks good — push to Calendar - + + {sheet} ) @@ -458,14 +497,19 @@ function WeekBoard({ Shopping list - - + @@ -507,10 +551,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 +563,7 @@ function SyncStatus({ plan }: { plan: Plan }) { return () => clearInterval(timer) }, []) return ( -

+

{status === 'synced' && } {syncStatuses[status]} {status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`} @@ -526,9 +571,38 @@ 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, +// 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) { + return ( + + Couldn't push to Calendar. {settings.reason}{' '} + + {settings.fix} + + + ) + } + return ( + + Couldn't push to Calendar.{' '} + {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} + + ) +} + +// 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.test.tsx b/web/src/plan/api.test.tsx index 438c2de..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, 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}` @@ -203,3 +204,198 @@ 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([]) + }) + + 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([]) + }) + + 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 bec55f3..0a8ee4a 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -1,8 +1,10 @@ -import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +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' import { QUEST_LOG_KEY } from '../discover/api.ts' +import { noteGoogleFailure } from '../settings/calendarApi.ts' export const PLANS_PATH = '/api/v1/plans' @@ -176,6 +178,82 @@ function sameDay(a: PlanDay, b: PlanDay): boolean { ) } +// 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]) } +} + +// 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 () => { + 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 // cache, where they outlive the Draft board that sent them. function swapKey(weekStart: string) { @@ -205,10 +283,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 (keepWeekMutations). export function useSwapDay(weekStart: string): SwapDay { const { apiFetch } = useApi() const queryClient = useQueryClient() + useWeekShown(weekStart) const key = planKey(weekStart) const mutationKey = swapKey(weekStart) const mutations = queryClient.getMutationCache() @@ -224,7 +304,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 settled in a week not shown + // (keepWeekMutations): a later swap must stay in the cache while an // earlier one is pending. gcTime: Infinity, mutationFn: async ({ date, dinner }) => { @@ -274,3 +355,85 @@ export function useSwapDay(weekStart: string): SwapDay { dismiss, } } + +// 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, 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. Settled +// pushes of a week no longer shown are left behind too +// (keepWeekMutations). +export function usePushToCalendar(weekStart: string): PushToCalendar { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + useWeekShown(weekStart) + const key = planKey(weekStart) + 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 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, + 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, + } +} diff --git a/web/src/settings/CalendarSettingsPage.module.css b/web/src/settings/CalendarSettingsPage.module.css new file mode 100644 index 0000000..3c6f7bf --- /dev/null +++ b/web/src/settings/CalendarSettingsPage.module.css @@ -0,0 +1,156 @@ +/* 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; +} + +.reconnectStatus { + display: flex; + flex-direction: column; + gap: 10px; +} + +.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..6803202 --- /dev/null +++ b/web/src/settings/CalendarSettingsPage.test.tsx @@ -0,0 +1,384 @@ +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() + + 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() + }) + + 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..ffb78b0 --- /dev/null +++ b/web/src/settings/CalendarSettingsPage.tsx @@ -0,0 +1,239 @@ +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. 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 ( +
+

+ 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),