Add the Google Calendar settings screen and wire Push to Calendar (Feature 16) (#129)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #129.
This commit is contained in:
@@ -36,6 +36,7 @@ const SCREENS = {
|
|||||||
'/settings/ai': true,
|
'/settings/ai': true,
|
||||||
'/settings/household': true,
|
'/settings/household': true,
|
||||||
'/settings/regulars': true,
|
'/settings/regulars': true,
|
||||||
|
'/settings/calendar': true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// SIGNED_IN answers what every route reads first: the session, then the
|
// 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())
|
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||||
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
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',
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PlanPage } from './plan/PlanPage.tsx'
|
|||||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||||
|
import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx'
|
||||||
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||||
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||||
@@ -43,6 +44,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
|||||||
'/settings/ai': <AISettingsPage />,
|
'/settings/ai': <AISettingsPage />,
|
||||||
'/settings/household': <HouseholdSettingsPage />,
|
'/settings/household': <HouseholdSettingsPage />,
|
||||||
'/settings/regulars': <RegularsSettingsPage />,
|
'/settings/regulars': <RegularsSettingsPage />,
|
||||||
|
'/settings/calendar': <CalendarSettingsPage />,
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||||
|
|||||||
@@ -258,9 +258,9 @@
|
|||||||
color: var(--color-neutral-700);
|
color: var(--color-neutral-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* An action with nothing behind it yet (Push to Calendar until Feature 16,
|
/* An action with nothing behind it yet (the shopping list until Feature
|
||||||
the shopping list until Feature 17): it keeps focus, so hovering, tapping
|
17): it keeps focus, so hovering, tapping or focusing it shows the
|
||||||
or focusing it shows the tooltip. */
|
tooltip. */
|
||||||
.unavailable {
|
.unavailable {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -406,6 +406,21 @@
|
|||||||
color: var(--color-accent-2-800);
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
+359
-12
@@ -15,6 +15,7 @@ const THIS_WEEK = '/api/v1/plans/2026-10-05'
|
|||||||
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
|
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
|
||||||
const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28'
|
const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28'
|
||||||
const DRAFT = `${THIS_WEEK}/draft`
|
const DRAFT = `${THIS_WEEK}/draft`
|
||||||
|
const PUSH = `${THIS_WEEK}/push-calendar`
|
||||||
const JOB = '/api/v1/jobs/j1'
|
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']
|
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<Response>((resolve) => {
|
||||||
|
send = resolve
|
||||||
|
})
|
||||||
|
return { response, send }
|
||||||
|
}
|
||||||
|
|
||||||
// draftJob is job j1 drafting the week of Oct 5 into plan planId: a
|
// draftJob is job j1 drafting the week of Oct 5 into plan planId: a
|
||||||
// redraft keeps the plan's id.
|
// redraft keeps the plan's id.
|
||||||
function draftJob(status: string, error: string | null = null, planId = 'plan-1') {
|
function draftJob(status: string, error: string | null = null, planId = 'plan-1') {
|
||||||
@@ -225,17 +241,312 @@ describe('PlanPage', () => {
|
|||||||
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
|
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('offers Push to Calendar unavailable, with a tooltip saying why', async () => {
|
it('pushes the plan to Calendar, showing progress, then the plan as synced', async () => {
|
||||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
|
const answer = deferred()
|
||||||
|
const fetchMock = stubServer({
|
||||||
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||||
|
[`POST ${PUSH}`]: () => answer.response,
|
||||||
|
})
|
||||||
await renderPage()
|
await renderPage()
|
||||||
|
|
||||||
const push = await screen.findByRole('button', { name: 'Looks good — push to Calendar' })
|
expect(await screen.findByText('Not pushed to Google Calendar yet')).toBeInTheDocument()
|
||||||
expect(push).toHaveAttribute('aria-disabled', 'true')
|
const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
|
||||||
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(declaredStyle(push).minHeight).toBe('50px')
|
expect(declaredStyle(push).minHeight).toBe('50px')
|
||||||
fireEvent.click(push)
|
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(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/plan']}>
|
||||||
|
<PlanPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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<Response> = () => 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<Response> = () => 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<Response> = () => 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 () => {
|
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.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
|
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
|
||||||
|
|
||||||
// Actions: the shopping list and Push to Calendar unavailable, saying
|
// Actions: the shopping list unavailable, saying why; Rebuild week
|
||||||
// why; Rebuild week outlined and working.
|
// outlined and Push to Calendar primary, both working.
|
||||||
const shopping = screen.getByRole('button', { name: 'Shopping list' })
|
const shopping = screen.getByRole('button', { name: 'Shopping list' })
|
||||||
expect(shopping).toHaveAttribute('aria-disabled', 'true')
|
expect(shopping).toHaveAttribute('aria-disabled', 'true')
|
||||||
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
|
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
|
||||||
const push = screen.getByRole('button', { name: 'Push to Calendar' })
|
const push = screen.getByRole('button', { name: 'Push to Calendar' })
|
||||||
expect(push).toHaveAttribute('aria-disabled', 'true')
|
expect(push).toBeEnabled()
|
||||||
expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.')
|
expect(push).not.toHaveAttribute('aria-disabled')
|
||||||
expect(declaredStyle(push).background).toBe('var(--color-accent)')
|
expect(declaredStyle(push).background).toBe('var(--color-accent)')
|
||||||
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
|
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
|
||||||
expect(rebuild).toBeEnabled()
|
expect(rebuild).toBeEnabled()
|
||||||
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
|
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
|
||||||
fireEvent.click(shopping)
|
fireEvent.click(shopping)
|
||||||
fireEvent.click(push)
|
|
||||||
expect(posts(fetchMock)).toEqual([])
|
expect(posts(fetchMock)).toEqual([])
|
||||||
// Mobile's own pieces are not shown.
|
// Mobile's own pieces are not shown.
|
||||||
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
|
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()
|
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') {
|
if (layout === 'desktop') {
|
||||||
it('shows the Quest log and Regulars in the 290 px right panel', async () => {
|
it('shows the Quest log and Regulars in the 290 px right panel', async () => {
|
||||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
|
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
|
||||||
|
|||||||
+103
-29
@@ -1,7 +1,8 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||||
import { useEffect, useId, useMemo, useState, type ReactNode } from '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 { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
parseDraftResult,
|
parseDraftResult,
|
||||||
planKey,
|
planKey,
|
||||||
usePlan,
|
usePlan,
|
||||||
|
usePushToCalendar,
|
||||||
useStartDraft,
|
useStartDraft,
|
||||||
useSwapDay,
|
useSwapDay,
|
||||||
type DraftResult,
|
type DraftResult,
|
||||||
@@ -62,9 +64,20 @@ const draftFailures: Record<string, string> = {
|
|||||||
internal_error: 'Something went wrong on the server.',
|
internal_error: 'Something went wrong on the server.',
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUSH_HINT is why Push to Calendar is unavailable.
|
// CALENDAR_SETTINGS is the Google Calendar settings screen.
|
||||||
// TODO(Feature 16): connect Push to Calendar once Google Calendar is.
|
const CALENDAR_SETTINGS = '/settings/calendar'
|
||||||
const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.'
|
|
||||||
|
// 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<string, { reason: string; fix: string }> = {
|
||||||
|
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.
|
// SHOPPING_HINT is why the shopping list is unavailable.
|
||||||
// TODO(Feature 17): open the Supply run, with its count, once it is built.
|
// 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,
|
// 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.
|
// 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
|
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to
|
||||||
// unavailable Shopping list and Push to Calendar are actions in the header,
|
// Calendar and the unavailable Shopping list are actions in the header,
|
||||||
// over the plan's sync status and the 7-column grid.
|
// 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({
|
function WeekBoard({
|
||||||
weekStart,
|
weekStart,
|
||||||
timezone,
|
timezone,
|
||||||
@@ -225,6 +242,8 @@ function WeekBoard({
|
|||||||
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const swap = useSwapDay(weekStart)
|
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.
|
// The date of the day being swapped, while the swap sheet is open.
|
||||||
const [swappingDate, setSwappingDate] = useState<string | null>(null)
|
const [swappingDate, setSwappingDate] = useState<string | null>(null)
|
||||||
const label = weekLabel(weekStart)
|
const label = weekLabel(weekStart)
|
||||||
@@ -282,13 +301,28 @@ function WeekBoard({
|
|||||||
|
|
||||||
const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null)
|
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
|
const { mutate } = startDraft
|
||||||
function start(confirm: boolean) {
|
function start(confirm: boolean) {
|
||||||
setConfirming(false)
|
setConfirming(false)
|
||||||
// A draft asked for again leaves the last one's failure, and a failed
|
// A draft asked for again leaves the last one's failure, a failed
|
||||||
// swap's, behind.
|
// swap's and a failed push's behind.
|
||||||
setJobId(null)
|
setJobId(null)
|
||||||
swap.dismiss()
|
swap.dismiss()
|
||||||
|
push.reset()
|
||||||
mutate(
|
mutate(
|
||||||
{ weekStart, confirm },
|
{ weekStart, confirm },
|
||||||
{
|
{
|
||||||
@@ -302,12 +336,14 @@ function WeekBoard({
|
|||||||
// It waits for a swap of the week still pending, which may yet fail: the
|
// It waits for a swap of the week still pending, which may yet fail: the
|
||||||
// Rebuild week tile is disabled meanwhile.
|
// Rebuild week tile is disabled meanwhile.
|
||||||
function rebuild() {
|
function rebuild() {
|
||||||
if (swap.pending) return
|
if (swap.pending || pushing) return
|
||||||
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
|
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
|
||||||
else start(false)
|
else start(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const failure = swap.failed !== null ? (
|
const failure = push.error !== null ? (
|
||||||
|
<PushFailure error={push.error} cached={cached} onRetry={cached ? () => void plan.refetch() : pushToCalendar} />
|
||||||
|
) : swap.failed !== null ? (
|
||||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||||
</ErrorBanner>
|
</ErrorBanner>
|
||||||
@@ -328,6 +364,7 @@ function WeekBoard({
|
|||||||
// swapIn sends the swap chosen in the sheet and closes it.
|
// swapIn sends the swap chosen in the sheet and closes it.
|
||||||
function swapIn(chosen: Swap) {
|
function swapIn(chosen: Swap) {
|
||||||
setSwappingDate(null)
|
setSwappingDate(null)
|
||||||
|
push.reset()
|
||||||
swap.mutate(chosen)
|
swap.mutate(chosen)
|
||||||
}
|
}
|
||||||
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
// 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
|
// 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
|
// 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) {
|
function openSwap(date: string) {
|
||||||
if (swap.pending) return
|
if (swap.pending || pushing) return
|
||||||
swap.dismiss()
|
swap.dismiss()
|
||||||
setSwappingDate(date)
|
setSwappingDate(date)
|
||||||
}
|
}
|
||||||
@@ -379,7 +417,7 @@ function WeekBoard({
|
|||||||
body =
|
body =
|
||||||
layout !== 'mobile' ? (
|
layout !== 'mobile' ? (
|
||||||
<>
|
<>
|
||||||
<DropBoard days={days} pending={swap.pending} drop={dropIn} />
|
<DropBoard days={days} pending={swap.pending || pushing} drop={dropIn} />
|
||||||
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<DayColumn
|
<DayColumn
|
||||||
@@ -414,15 +452,16 @@ function WeekBoard({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<li className={styles.rebuildCell}>
|
<li className={styles.rebuildCell}>
|
||||||
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending}>
|
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending || pushing}>
|
||||||
<Icon icon={Plus} size="inline" />
|
<Icon icon={Plus} size="inline" />
|
||||||
Rebuild week
|
Rebuild week
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Unavailable hint={PUSH_HINT} className={styles.pushButton}>
|
<Button className={styles.pushButton} onClick={pushToCalendar} disabled={!canPush} aria-busy={pushing}>
|
||||||
Looks good — push to Calendar
|
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
|
||||||
</Unavailable>
|
</Button>
|
||||||
|
<SyncStatus plan={plan.data} className={styles.mobileStatus} />
|
||||||
{sheet}
|
{sheet}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -458,14 +497,19 @@ function WeekBoard({
|
|||||||
<Icon icon={ShoppingCart} size="inline" />
|
<Icon icon={ShoppingCart} size="inline" />
|
||||||
Shopping list
|
Shopping list
|
||||||
</Unavailable>
|
</Unavailable>
|
||||||
<Button variant="outline" className={styles.action} onClick={rebuild} disabled={swap.pending || drafting || !plan.isSuccess}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={styles.action}
|
||||||
|
onClick={rebuild}
|
||||||
|
disabled={swap.pending || pushing || drafting || !plan.isSuccess}
|
||||||
|
>
|
||||||
<Icon icon={RefreshCw} size="inline" />
|
<Icon icon={RefreshCw} size="inline" />
|
||||||
Rebuild week
|
Rebuild week
|
||||||
</Button>
|
</Button>
|
||||||
<Unavailable hint={PUSH_HINT} below className={styles.action}>
|
<Button className={styles.action} onClick={pushToCalendar} disabled={!canPush} aria-busy={pushing}>
|
||||||
<Icon icon={CalendarDays} size="inline" />
|
<Icon icon={CalendarDays} size="inline" />
|
||||||
Push to Calendar
|
{pushing ? 'Pushing…' : 'Push to Calendar'}
|
||||||
</Unavailable>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -507,10 +551,11 @@ function WeekBoard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncStatus is the plan manager's status line: whether the plan is on
|
// SyncStatus is the plan's status line, in the plan manager's header and
|
||||||
// Google Calendar and, once synced, how long ago it was pushed, kept current
|
// under the Draft board's push button: whether the plan is on Google
|
||||||
// minute by minute.
|
// Calendar and, once synced, how long ago it was pushed, kept current minute
|
||||||
function SyncStatus({ plan }: { plan: Plan }) {
|
// by minute.
|
||||||
|
function SyncStatus({ plan, className }: { plan: Plan; className?: string }) {
|
||||||
const { status, calendar_synced_at: syncedAt } = plan
|
const { status, calendar_synced_at: syncedAt } = plan
|
||||||
const [now, setNow] = useState(() => new Date())
|
const [now, setNow] = useState(() => new Date())
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -518,7 +563,7 @@ function SyncStatus({ plan }: { plan: Plan }) {
|
|||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [])
|
}, [])
|
||||||
return (
|
return (
|
||||||
<p className={[styles.status, status === 'synced' && styles.synced].filter(Boolean).join(' ')}>
|
<p className={[styles.status, status === 'synced' && styles.synced, className].filter(Boolean).join(' ')}>
|
||||||
{status === 'synced' && <Icon icon={Check} size="inline" />}
|
{status === 'synced' && <Icon icon={Check} size="inline" />}
|
||||||
{syncStatuses[status]}
|
{syncStatuses[status]}
|
||||||
{status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}
|
{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
|
// PushFailure says why Push to Calendar failed. A failure only the Google
|
||||||
// shopping list): it stays focusable, so a tap or focus shows the tooltip
|
// Calendar settings can fix links there; any other offers to push again,
|
||||||
// saying why, above it or, at the top of the page, below.
|
// 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 (
|
||||||
|
<ErrorBanner>
|
||||||
|
Couldn't push to Calendar. {settings.reason}{' '}
|
||||||
|
<Link to={CALENDAR_SETTINGS} className={styles.settingsLink}>
|
||||||
|
{settings.fix}
|
||||||
|
</Link>
|
||||||
|
</ErrorBanner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ErrorBanner onRetry={onRetry}>
|
||||||
|
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}
|
||||||
|
</ErrorBanner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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({
|
function Unavailable({
|
||||||
hint,
|
hint,
|
||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
|
|||||||
+198
-2
@@ -1,11 +1,12 @@
|
|||||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
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 type { ReactNode } from 'react'
|
||||||
|
import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.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 WEEK = '2026-10-05'
|
||||||
const PLAN = `/api/v1/plans/${WEEK}`
|
const PLAN = `/api/v1/plans/${WEEK}`
|
||||||
@@ -203,3 +204,198 @@ describe('useSwapDay', () => {
|
|||||||
await act(async () => friday.send(jsonResponse(200, planBody())))
|
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 }) => (
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
)
|
||||||
|
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 }) => (
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
)
|
||||||
|
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<typeof usePlan>; 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(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/plan']}>
|
||||||
|
<Navigator />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/plan" element={<Week />} />
|
||||||
|
<Route path="/settings" element={null} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
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<Plan>(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+166
-3
@@ -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 { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||||
|
import { noteGoogleFailure } from '../settings/calendarApi.ts'
|
||||||
|
|
||||||
export const PLANS_PATH = '/api/v1/plans'
|
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<QueryClient, WeeksShown>()
|
||||||
|
|
||||||
|
// 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<string, number>()
|
||||||
|
function sweep(settled: boolean) {
|
||||||
|
// The mutations of each kind of each week not shown, in the order sent.
|
||||||
|
const left = new Map<string, Mutation[]>()
|
||||||
|
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
|
// swapKey keys the swaps of the week starting on weekStart in the mutation
|
||||||
// cache, where they outlive the Draft board that sent them.
|
// cache, where they outlive the Draft board that sent them.
|
||||||
function swapKey(weekStart: string) {
|
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
|
// 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
|
// 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
|
// 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 {
|
export function useSwapDay(weekStart: string): SwapDay {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
useWeekShown(weekStart)
|
||||||
const key = planKey(weekStart)
|
const key = planKey(weekStart)
|
||||||
const mutationKey = swapKey(weekStart)
|
const mutationKey = swapKey(weekStart)
|
||||||
const mutations = queryClient.getMutationCache()
|
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 isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
|
||||||
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
||||||
mutationKey,
|
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.
|
// earlier one is pending.
|
||||||
gcTime: Infinity,
|
gcTime: Infinity,
|
||||||
mutationFn: async ({ date, dinner }) => {
|
mutationFn: async ({ date, dinner }) => {
|
||||||
@@ -274,3 +355,85 @@ export function useSwapDay(weekStart: string): SwapDay {
|
|||||||
dismiss,
|
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<Plan, Error, void, unknown>(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<unknown>(path, { method: 'POST' }), false)
|
||||||
|
},
|
||||||
|
onSuccess: (plan) => queryClient.setQueryData<Plan>(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Response>
|
||||||
|
|
||||||
|
// 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<string, Responder>): Mock<typeof fetch> {
|
||||||
|
const all: Record<string, Responder> = { 'GET /api/v1/me': () => jsonResponse(200, ME), ...responders }
|
||||||
|
const fetchMock = vi.fn<typeof fetch>((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<typeof fetch>) {
|
||||||
|
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<typeof fetch>, 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<Response>((resolve) => {
|
||||||
|
send = resolve
|
||||||
|
})
|
||||||
|
return { response, send }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
function CurrentPath() {
|
||||||
|
return <p data-testid="path">{useLocation().pathname}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<InDataRouter path="/settings/calendar">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/settings/calendar" element={<CalendarSettingsPage />} />
|
||||||
|
<Route path="*" element={<CurrentPath />} />
|
||||||
|
</Routes>
|
||||||
|
</InDataRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
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 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Header />
|
||||||
|
<p className={styles.shared}>
|
||||||
|
Push to Calendar adds one event per dinner. It is one-way: edits made in Google Calendar do not come back.
|
||||||
|
</p>
|
||||||
|
{connection.isPending && <Skeleton className={styles.loading} />}
|
||||||
|
{connection.isError && (
|
||||||
|
<ErrorBanner onRetry={() => void connection.refetch()}>
|
||||||
|
Google Calendar status could not load: {connection.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
{connection.isSuccess && <Connection connection={connection.data} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
|
||||||
|
<Icon icon={ChevronLeft} size="nav" />
|
||||||
|
</IconButton>
|
||||||
|
<div>
|
||||||
|
<p className={styles.kicker}>Settings</p>
|
||||||
|
<h1 className={styles.title}>Google Calendar</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Connection({ connection }: { connection: GoogleConnection }) {
|
||||||
|
switch (connection.status) {
|
||||||
|
case 'not_connected':
|
||||||
|
return (
|
||||||
|
<Section title="Not connected">
|
||||||
|
<p className={styles.text}>Connect the household's Google account to push each week's plan to one of its calendars.</p>
|
||||||
|
<ConnectLink>Connect Google Calendar</ConnectLink>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
case 'reconnect_required':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReconnectBanner error={connection.last_sync_error} />
|
||||||
|
<Section title="Calendar">
|
||||||
|
<p className={styles.text}>
|
||||||
|
{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.`}
|
||||||
|
</p>
|
||||||
|
<Disconnect calendar={connection.calendar_name} />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
case 'connected':
|
||||||
|
return (
|
||||||
|
<Section title="Calendar">
|
||||||
|
<p className={styles.connected}>
|
||||||
|
<Icon icon={Check} size="inline" />
|
||||||
|
{connection.calendar_name === null ? 'Connected' : `Connected · ${connection.calendar_name}`}
|
||||||
|
</p>
|
||||||
|
<CalendarPicker connection={connection} />
|
||||||
|
<Disconnect calendar={connection.calendar_name} />
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<section className={styles.reconnect} aria-labelledby={titleId}>
|
||||||
|
<h2 id={titleId} className={styles.reconnectTitle}>
|
||||||
|
Reconnect Google Calendar
|
||||||
|
</h2>
|
||||||
|
<div role="alert" className={styles.reconnectStatus}>
|
||||||
|
{error !== null && <p className={styles.reconnectText}>{error}</p>}
|
||||||
|
<p className={styles.reconnectText}>Nothing can be pushed to Calendar until you connect again.</p>
|
||||||
|
</div>
|
||||||
|
<ConnectLink icon={RefreshCw}>Reconnect</ConnectLink>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
const titleId = useId()
|
||||||
|
return (
|
||||||
|
<Card className={styles.card} role="region" aria-labelledby={titleId}>
|
||||||
|
<h2 id={titleId} className={styles.cardTitle}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<a href={GOOGLE_CONNECT_PATH} className={[buttonStyles.button, buttonStyles.primary, styles.connect].join(' ')}>
|
||||||
|
<Icon icon={icon} size="inline" />
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <Skeleton className={styles.pickerLoading} />
|
||||||
|
if (calendars.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorBanner onRetry={() => void calendars.refetch()}>
|
||||||
|
Your calendars could not load: {calendars.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (calendars.data.length === 0) {
|
||||||
|
return <p className={styles.warning}>This Google account has no calendar MealQuest can add dinners to.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && <p className={styles.text}>Choose the calendar to push dinners to.</p>}
|
||||||
|
{connection.calendar_id !== null && !listed && (
|
||||||
|
<p className={styles.warning}>
|
||||||
|
{connection.calendar_name} was deleted or MealQuest can no longer add dinners to it. Choose another calendar.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Select
|
||||||
|
label="Calendar"
|
||||||
|
value={value}
|
||||||
|
disabled={choose.isPending}
|
||||||
|
onChange={(event) => {
|
||||||
|
choose.reset()
|
||||||
|
choose.mutate(event.target.value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value === '' && (
|
||||||
|
<option value="" disabled>
|
||||||
|
Choose a calendar
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{calendars.data.map((calendar) => (
|
||||||
|
<option key={calendar.id} value={calendar.id}>
|
||||||
|
{calendar.primary ? `${calendar.summary} (primary)` : calendar.summary}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<p className={styles.status} role="status">
|
||||||
|
{choose.isPending && 'Saving…'}
|
||||||
|
</p>
|
||||||
|
{choose.isError && (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
Not saved: {choose.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className={styles.hint}>
|
||||||
|
After choosing another calendar, the next push writes the whole week there. Dinners already pushed stay where they
|
||||||
|
are.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect revokes MealQuest's access at Google, after asking.
|
||||||
|
function Disconnect({ calendar }: { calendar: string | null }) {
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const disconnect = useDisconnectGoogle()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
disconnect.reset()
|
||||||
|
setConfirming(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Confirm
|
||||||
|
open={confirming}
|
||||||
|
title="Disconnect Google Calendar?"
|
||||||
|
confirmLabel={disconnect.isPending ? 'Disconnecting…' : 'Disconnect'}
|
||||||
|
cancelLabel="Keep connected"
|
||||||
|
busy={disconnect.isPending}
|
||||||
|
dismissible={!disconnect.isPending}
|
||||||
|
error={disconnect.isError ? `Not disconnected: ${disconnect.error.message}` : null}
|
||||||
|
onCancel={() => 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.
|
||||||
|
</Confirm>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Route, Routes, useLocation } from 'react-router'
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.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 { declaredStyle } from '../test/cssRules.ts'
|
||||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
@@ -22,7 +22,7 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const REGULARS_PATH = '/api/v1/dinners?status=regular'
|
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'
|
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
|
||||||
|
|
||||||
// stubSettings answers as today's server does (see SETTINGS_API), with
|
// 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(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||||
REGULARS_PATH,
|
REGULARS_PATH,
|
||||||
|
CALENDAR_PATH,
|
||||||
'/api/v1/settings/ai',
|
'/api/v1/settings/ai',
|
||||||
'/api/v1/settings/ai/models',
|
'/api/v1/settings/ai/models',
|
||||||
CALENDAR_PATH,
|
|
||||||
'/api/v1/settings/household',
|
'/api/v1/settings/household',
|
||||||
NOTIFICATIONS_PATH,
|
NOTIFICATIONS_PATH,
|
||||||
'/api/v1/settings/recipe-search',
|
'/api/v1/settings/recipe-search',
|
||||||
@@ -109,7 +109,6 @@ describe('SettingsPage', () => {
|
|||||||
stubSettings()
|
stubSettings()
|
||||||
renderSettings()
|
renderSettings()
|
||||||
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
|
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
|
||||||
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
|
|
||||||
expect(
|
expect(
|
||||||
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
|
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
@@ -119,7 +118,8 @@ describe('SettingsPage', () => {
|
|||||||
it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => {
|
it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => {
|
||||||
stubSettings({
|
stubSettings({
|
||||||
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
|
[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' }),
|
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }),
|
||||||
})
|
})
|
||||||
renderSettings()
|
renderSettings()
|
||||||
@@ -133,7 +133,7 @@ describe('SettingsPage', () => {
|
|||||||
it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => {
|
it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => {
|
||||||
stubSettings({
|
stubSettings({
|
||||||
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
|
[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 }),
|
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }),
|
||||||
})
|
})
|
||||||
renderSettings()
|
renderSettings()
|
||||||
@@ -144,6 +144,15 @@ describe('SettingsPage', () => {
|
|||||||
).toBeInTheDocument()
|
).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 () => {
|
it('reports a status endpoint failure other than a missing endpoint', async () => {
|
||||||
stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') })
|
stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') })
|
||||||
renderSettings()
|
renderSettings()
|
||||||
@@ -151,10 +160,10 @@ describe('SettingsPage', () => {
|
|||||||
expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar')
|
expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refuses a connected calendar without a name', async () => {
|
it('refuses a Google Calendar status it does not know', async () => {
|
||||||
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { connected: true }) })
|
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'paused' }) })
|
||||||
renderSettings()
|
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 () => {
|
it('shows another provider and model as the settings name them', async () => {
|
||||||
|
|||||||
@@ -11,16 +11,15 @@ import { Tag } from '../ui/Tag.tsx'
|
|||||||
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
||||||
import {
|
import {
|
||||||
aiStatus,
|
aiStatus,
|
||||||
calendarStatus,
|
|
||||||
formatClockTime,
|
formatClockTime,
|
||||||
notificationsStatus,
|
notificationsStatus,
|
||||||
regularDinnersStatus,
|
regularDinnersStatus,
|
||||||
useAISettings,
|
useAISettings,
|
||||||
useCalendarStatus,
|
|
||||||
useHouseholdSettings,
|
useHouseholdSettings,
|
||||||
useNotificationsStatus,
|
useNotificationsStatus,
|
||||||
useRegularDinnersCount,
|
useRegularDinnersCount,
|
||||||
} from './api.ts'
|
} from './api.ts'
|
||||||
|
import { calendarStatus, useGoogleConnection } from './calendarApi.ts'
|
||||||
import styles from './SettingsPage.module.css'
|
import styles from './SettingsPage.module.css'
|
||||||
import { SettingsTabs } from './SettingsTabs.tsx'
|
import { SettingsTabs } from './SettingsTabs.tsx'
|
||||||
|
|
||||||
@@ -39,14 +38,14 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
||||||
// page and shows a live status line. Regular dinners, Google Calendar and
|
// page and shows a live status line. Regular dinners and Notifications &
|
||||||
// Notifications & Google Chat read endpoints their features have not served
|
// Google Chat read endpoints their features have not served yet; until then
|
||||||
// yet; until then each shows its not-yet-available state (see api.ts).
|
// each shows its not-yet-available state (see api.ts).
|
||||||
function SettingsList() {
|
function SettingsList() {
|
||||||
const household = useHouseholdSettings()
|
const household = useHouseholdSettings()
|
||||||
const ai = useAISettings()
|
const ai = useAISettings()
|
||||||
const regulars = useRegularDinnersCount()
|
const regulars = useRegularDinnersCount()
|
||||||
const calendar = useCalendarStatus()
|
const calendar = useGoogleConnection()
|
||||||
const notifications = useNotificationsStatus()
|
const notifications = useNotificationsStatus()
|
||||||
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
|
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
|
||||||
|
|
||||||
|
|||||||
@@ -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 { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.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 { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.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 () => {
|
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
||||||
stubAPI({
|
stubAPI({
|
||||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'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'),
|
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||||
})
|
})
|
||||||
const resize = await renderAt(767)
|
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 () => {
|
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
|
||||||
stubAPI({
|
stubAPI({
|
||||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'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'),
|
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||||
})
|
})
|
||||||
const resize = await renderAt(1180)
|
const resize = await renderAt(1180)
|
||||||
|
|||||||
@@ -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 read API. Until it lands the server answers 404 and the Regular
|
||||||
// dinners row says the count is unavailable.
|
// dinners row says the count is unavailable.
|
||||||
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
|
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
|
// 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
|
// is served the server answers 404, which is the truth: push is off and no
|
||||||
// Google Chat space is connected.
|
// Google Chat space is connected.
|
||||||
@@ -516,30 +513,6 @@ export function regularDinnersStatus(count: number | null): string {
|
|||||||
return `${count} on rotation`
|
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
|
// NotificationsStatus is whether Web Push is on and the connected Google
|
||||||
// Chat space's name, null when none is.
|
// Chat space's name, null when none is.
|
||||||
export type NotificationsStatus = { push: boolean; chat_space: string | null }
|
export type NotificationsStatus = { push: boolean; chat_space: string | null }
|
||||||
|
|||||||
@@ -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<unknown>(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<GoogleCalendar[]> => {
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await apiFetch<unknown>(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<string, unknown>) => ({ 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<unknown>(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<GoogleConnection>(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<void>(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}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,13 +51,23 @@ export const RECIPE_SEARCH_SETTINGS = {
|
|||||||
updated_at: '2026-10-01T15:00:00.000Z',
|
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
|
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||||
// today's server does: the dinners, Google Calendar and notifications status
|
// today's server does: the dinners and notifications status endpoints do not
|
||||||
// endpoints do not exist yet. The home's sub-tabs stay mounted on mobile, so
|
// 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.
|
// it also reads the AI models and recipe search settings.
|
||||||
export const SETTINGS_API = {
|
export const SETTINGS_API = {
|
||||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'/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/notifications': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
|
|||||||
Reference in New Issue
Block a user