Add the Google Calendar settings screen and wire Push to Calendar (Feature 16)
/settings/calendar now shows the Google Calendar connection: "Connect
Google Calendar" (a full-page navigation to the connect endpoint) when not
connected; "Connected · {calendar}" in teal-800 with a picker of the
writable calendars, saved on choice, and Disconnect behind a confirmation
when connected; and a grape banner with Reconnect, no picker, when Google
has refused the grant. The settings list's Google Calendar row reads the
same connection instead of the placeholder endpoint.
The plan screens' "Looks good — push to Calendar" and "Push to Calendar"
buttons now POST /api/v1/plans/{weekStart}/push-calendar, showing progress
and storing the synced plan the server answers with. Failures only the
settings can fix (google_reconnect_required, no_calendar, not_connected,
calendar_unavailable) link to /settings/calendar; plan_changed reads the
plan again; any other failure offers Retry. The mobile Draft board shows
the existing sync status line under its push button.
Maestro task 677261bc-1a7c-4d96-b8f9-7601caf2186d.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ const SCREENS = {
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
'/settings/regulars': true,
|
||||
'/settings/calendar': true,
|
||||
}
|
||||
|
||||
// SIGNED_IN answers what every route reads first: the session, then the
|
||||
@@ -353,4 +354,20 @@ describe('App offline bootstrap', () => {
|
||||
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings/calendar renders the Google Calendar screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/integrations/google': () =>
|
||||
jsonResponse(200, { status: 'not_connected', calendar_id: null, calendar_name: null, connected_at: null, last_sync_error: null }),
|
||||
})
|
||||
renderAt('/settings/calendar')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Google Calendar' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toHaveAttribute(
|
||||
'href',
|
||||
'/api/v1/integrations/google/connect',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PlanPage } from './plan/PlanPage.tsx'
|
||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||
import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx'
|
||||
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||
@@ -43,6 +44,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
'/settings/regulars': <RegularsSettingsPage />,
|
||||
'/settings/calendar': <CalendarSettingsPage />,
|
||||
}
|
||||
|
||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||
|
||||
@@ -258,9 +258,9 @@
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* An action with nothing behind it yet (Push to Calendar until Feature 16,
|
||||
the shopping list until Feature 17): it keeps focus, so hovering, tapping
|
||||
or focusing it shows the tooltip. */
|
||||
/* An action with nothing behind it yet (the shopping list until Feature
|
||||
17): it keeps focus, so hovering, tapping or focusing it shows the
|
||||
tooltip. */
|
||||
.unavailable {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -406,6 +406,21 @@
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
/* Under the Draft board's push button the line is centred and may wrap. */
|
||||
.mobileStatus {
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* A push failure's way to the Google Calendar settings. */
|
||||
.settingsLink {
|
||||
color: inherit;
|
||||
font-weight: 800;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
+202
-12
@@ -15,6 +15,7 @@ const THIS_WEEK = '/api/v1/plans/2026-10-05'
|
||||
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
|
||||
const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28'
|
||||
const DRAFT = `${THIS_WEEK}/draft`
|
||||
const PUSH = `${THIS_WEEK}/push-calendar`
|
||||
const JOB = '/api/v1/jobs/j1'
|
||||
|
||||
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||
@@ -50,6 +51,21 @@ function planBody(status: string, names = NAMES) {
|
||||
}
|
||||
}
|
||||
|
||||
// syncedNow is the plan of the week of Oct 5 as a push answers it: synced
|
||||
// at the tests' noon.
|
||||
function syncedNow() {
|
||||
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||
}
|
||||
|
||||
// deferred is a response the test sends when it chooses.
|
||||
function deferred() {
|
||||
let send!: (response: Response) => void
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
send = resolve
|
||||
})
|
||||
return { response, send }
|
||||
}
|
||||
|
||||
// draftJob is job j1 drafting the week of Oct 5 into plan planId: a
|
||||
// redraft keeps the plan's id.
|
||||
function draftJob(status: string, error: string | null = null, planId = 'plan-1') {
|
||||
@@ -225,17 +241,155 @@ describe('PlanPage', () => {
|
||||
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers Push to Calendar unavailable, with a tooltip saying why', async () => {
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
|
||||
it('pushes the plan to Calendar, showing progress, then the plan as synced', async () => {
|
||||
const answer = deferred()
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => answer.response,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const push = await screen.findByRole('button', { name: 'Looks good — push to Calendar' })
|
||||
expect(push).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.')
|
||||
expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Push to Calendar arrives once Google Calendar can be connected.')
|
||||
expect(await screen.findByText('Not pushed to Google Calendar yet')).toBeInTheDocument()
|
||||
const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
|
||||
expect(declaredStyle(push).minHeight).toBe('50px')
|
||||
fireEvent.click(push)
|
||||
expect(posts(fetchMock)).toEqual([])
|
||||
await waitFor(() => expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]))
|
||||
const call = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||
expect(new Headers(call[1]?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
|
||||
|
||||
// In progress: the button says so and is disabled, as is Rebuild week,
|
||||
// and no day can be swapped.
|
||||
const pushing = await screen.findByRole('button', { name: 'Pushing to Calendar…' })
|
||||
expect(pushing).toBeDisabled()
|
||||
expect(pushing).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled()
|
||||
fireEvent.click(within(tile('MON')).getByRole('button'))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
answer.send(jsonResponse(200, syncedNow()))
|
||||
const status = await screen.findByText('Synced to Google Calendar just now')
|
||||
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
|
||||
expect(status.querySelector('svg')).not.toBeNull()
|
||||
expect(screen.getByText('Synced')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
// The plan shown is the one the push answered with: no second read.
|
||||
expect(planReads(fetchMock)).toBe(1)
|
||||
})
|
||||
|
||||
it('says a synced plan is on Google Calendar, and how long ago it was pushed', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')) })
|
||||
await renderPage()
|
||||
|
||||
const status = await screen.findByText('Synced to Google Calendar 2 min ago')
|
||||
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
|
||||
expect(status.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('says changes to a pushed plan are not pushed, and pushes them', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow()),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByText('Changes not pushed')).toBeInTheDocument()
|
||||
const status = screen.getByText('Changes not pushed to Google Calendar')
|
||||
expect(declaredStyle(status).color).toBe('var(--color-neutral-700)')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Changes not pushed')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['google_reconnect_required', 'Google access was revoked or expired.', 'Reconnect Google Calendar'],
|
||||
['no_calendar', 'No calendar is chosen for dinners yet.', 'Choose a calendar in Settings'],
|
||||
['not_connected', 'Google Calendar is not connected.', 'Connect Google Calendar'],
|
||||
['calendar_unavailable', 'The chosen calendar was deleted or MealQuest can no longer add dinners to it.', 'Choose another calendar'],
|
||||
])('sends a %s push failure to the Google Calendar settings', async (code, reason, fix) => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => errorResponse(409, code),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent(`Couldn't push to Calendar. ${reason} ${fix}`)
|
||||
expect(within(alert).getByRole('link', { name: fix })).toHaveAttribute('href', '/settings/calendar')
|
||||
expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||
// The plan is as it was: not synced.
|
||||
expect(screen.getByText('Draft')).toBeInTheDocument()
|
||||
expect(screen.getByText('Not pushed to Google Calendar yet')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('marks the Google Calendar connection to be read again when Google refused the grant', async () => {
|
||||
const connection = { status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners', connected_at: null, last_sync_error: null }
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const queryClient = createQueryClient()
|
||||
queryClient.setQueryData(['integrations', 'google'], connection)
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'),
|
||||
})
|
||||
await api.loadMe()
|
||||
render(
|
||||
<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('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => {
|
||||
@@ -715,20 +869,19 @@ describe.each([
|
||||
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
|
||||
|
||||
// Actions: the shopping list and Push to Calendar unavailable, saying
|
||||
// why; Rebuild week outlined and working.
|
||||
// Actions: the shopping list unavailable, saying why; Rebuild week
|
||||
// outlined and Push to Calendar primary, both working.
|
||||
const shopping = screen.getByRole('button', { name: 'Shopping list' })
|
||||
expect(shopping).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
|
||||
const push = screen.getByRole('button', { name: 'Push to Calendar' })
|
||||
expect(push).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.')
|
||||
expect(push).toBeEnabled()
|
||||
expect(push).not.toHaveAttribute('aria-disabled')
|
||||
expect(declaredStyle(push).background).toBe('var(--color-accent)')
|
||||
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
|
||||
expect(rebuild).toBeEnabled()
|
||||
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
|
||||
fireEvent.click(shopping)
|
||||
fireEvent.click(push)
|
||||
expect(posts(fetchMock)).toEqual([])
|
||||
// Mobile's own pieces are not shown.
|
||||
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
|
||||
@@ -795,6 +948,43 @@ describe.each([
|
||||
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('pushes the plan from the header, showing progress, then the plan as synced', async () => {
|
||||
const answer = deferred()
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')),
|
||||
[`POST ${PUSH}`]: () => answer.response,
|
||||
...lists,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('Beef tacos')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' }))
|
||||
const pushing = await screen.findByRole('button', { name: 'Pushing…' })
|
||||
expect(pushing).toBeDisabled()
|
||||
expect(pushing).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled()
|
||||
|
||||
answer.send(jsonResponse(200, syncedNow()))
|
||||
const status = await screen.findByText('Synced to Google Calendar just now')
|
||||
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
|
||||
expect(screen.getByRole('button', { name: 'Push to Calendar' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('links a google_reconnect_required push failure to the Google Calendar settings', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')),
|
||||
[`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'),
|
||||
...lists,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('Beef tacos')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(within(alert).getByRole('link', { name: 'Reconnect Google Calendar' })).toHaveAttribute('href', '/settings/calendar')
|
||||
expect(screen.getByText('Synced to Google Calendar 2 min ago')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
if (layout === 'desktop') {
|
||||
it('shows the Quest log and Regulars in the 290 px right panel', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
|
||||
|
||||
+92
-29
@@ -1,7 +1,8 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useJob } from '../api/index.ts'
|
||||
import { Link } from 'react-router'
|
||||
import { ApiError, useJob } from '../api/index.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
parseDraftResult,
|
||||
planKey,
|
||||
usePlan,
|
||||
usePushToCalendar,
|
||||
useStartDraft,
|
||||
useSwapDay,
|
||||
type DraftResult,
|
||||
@@ -62,9 +64,20 @@ const draftFailures: Record<string, string> = {
|
||||
internal_error: 'Something went wrong on the server.',
|
||||
}
|
||||
|
||||
// PUSH_HINT is why Push to Calendar is unavailable.
|
||||
// TODO(Feature 16): connect Push to Calendar once Google Calendar is.
|
||||
const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.'
|
||||
// CALENDAR_SETTINGS is the Google Calendar settings screen.
|
||||
const CALENDAR_SETTINGS = '/settings/calendar'
|
||||
|
||||
// settingsFailures says why a push failed, by its API error code, for the
|
||||
// failures only the Google Calendar settings can fix, and what to do there.
|
||||
const settingsFailures: Record<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.
|
||||
// TODO(Feature 17): open the Supply run, with its count, once it is built.
|
||||
@@ -204,9 +217,13 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
|
||||
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
|
||||
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
|
||||
//
|
||||
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week and the
|
||||
// unavailable Shopping list and Push to Calendar are actions in the header,
|
||||
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to
|
||||
// Calendar and the unavailable Shopping list are actions in the header,
|
||||
// over the plan's sync status and the 7-column grid.
|
||||
//
|
||||
// Push to Calendar, on either layout, sends the plan shown and shows it as
|
||||
// synced once the server answers; a failure says why, linking to the Google
|
||||
// Calendar settings when only they can fix it.
|
||||
function WeekBoard({
|
||||
weekStart,
|
||||
timezone,
|
||||
@@ -225,6 +242,8 @@ function WeekBoard({
|
||||
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const swap = useSwapDay(weekStart)
|
||||
const push = usePushToCalendar(weekStart)
|
||||
const pushing = push.isPending
|
||||
// The date of the day being swapped, while the swap sheet is open.
|
||||
const [swappingDate, setSwappingDate] = useState<string | null>(null)
|
||||
const label = weekLabel(weekStart)
|
||||
@@ -282,13 +301,23 @@ function WeekBoard({
|
||||
|
||||
const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null)
|
||||
|
||||
// A push sends the plan shown: it waits for a draft or a swap of the week
|
||||
// to settle, and neither starts while it runs.
|
||||
const canPush = plan.isSuccess && plan.data !== null && !drafting && !swap.pending && !pushing
|
||||
function pushToCalendar() {
|
||||
if (!canPush) return
|
||||
swap.dismiss()
|
||||
push.mutate()
|
||||
}
|
||||
|
||||
const { mutate } = startDraft
|
||||
function start(confirm: boolean) {
|
||||
setConfirming(false)
|
||||
// A draft asked for again leaves the last one's failure, and a failed
|
||||
// swap's, behind.
|
||||
// A draft asked for again leaves the last one's failure, a failed
|
||||
// swap's and a failed push's behind.
|
||||
setJobId(null)
|
||||
swap.dismiss()
|
||||
push.reset()
|
||||
mutate(
|
||||
{ weekStart, confirm },
|
||||
{
|
||||
@@ -302,12 +331,14 @@ function WeekBoard({
|
||||
// It waits for a swap of the week still pending, which may yet fail: the
|
||||
// Rebuild week tile is disabled meanwhile.
|
||||
function rebuild() {
|
||||
if (swap.pending) return
|
||||
if (swap.pending || pushing) return
|
||||
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
|
||||
else start(false)
|
||||
}
|
||||
|
||||
const failure = swap.failed !== null ? (
|
||||
const failure = push.isError ? (
|
||||
<PushFailure error={push.error} onRetry={pushToCalendar} />
|
||||
) : swap.failed !== null ? (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
</ErrorBanner>
|
||||
@@ -328,6 +359,7 @@ function WeekBoard({
|
||||
// swapIn sends the swap chosen in the sheet and closes it.
|
||||
function swapIn(chosen: Swap) {
|
||||
setSwappingDate(null)
|
||||
push.reset()
|
||||
swap.mutate(chosen)
|
||||
}
|
||||
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
||||
@@ -338,9 +370,10 @@ function WeekBoard({
|
||||
}
|
||||
// openSwap opens the swap sheet on a day. One swap of the week is sent at
|
||||
// a time, even across leaving the week and coming back: the plan the
|
||||
// server answers it with must not overwrite another still pending.
|
||||
// server answers it with must not overwrite another still pending. Nor is
|
||||
// one sent while the week is being pushed to Calendar.
|
||||
function openSwap(date: string) {
|
||||
if (swap.pending) return
|
||||
if (swap.pending || pushing) return
|
||||
swap.dismiss()
|
||||
setSwappingDate(date)
|
||||
}
|
||||
@@ -379,7 +412,7 @@ function WeekBoard({
|
||||
body =
|
||||
layout !== 'mobile' ? (
|
||||
<>
|
||||
<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">
|
||||
{days.map((day) => (
|
||||
<DayColumn
|
||||
@@ -414,15 +447,16 @@ function WeekBoard({
|
||||
/>
|
||||
))}
|
||||
<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" />
|
||||
Rebuild week
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<Unavailable hint={PUSH_HINT} className={styles.pushButton}>
|
||||
Looks good — push to Calendar
|
||||
</Unavailable>
|
||||
<Button className={styles.pushButton} onClick={pushToCalendar} disabled={!canPush} aria-busy={pushing}>
|
||||
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
|
||||
</Button>
|
||||
<SyncStatus plan={plan.data} className={styles.mobileStatus} />
|
||||
{sheet}
|
||||
</>
|
||||
)
|
||||
@@ -458,14 +492,19 @@ function WeekBoard({
|
||||
<Icon icon={ShoppingCart} size="inline" />
|
||||
Shopping list
|
||||
</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" />
|
||||
Rebuild week
|
||||
</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" />
|
||||
Push to Calendar
|
||||
</Unavailable>
|
||||
{pushing ? 'Pushing…' : 'Push to Calendar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -507,10 +546,11 @@ function WeekBoard({
|
||||
)
|
||||
}
|
||||
|
||||
// SyncStatus is the plan manager's status line: whether the plan is on
|
||||
// Google Calendar and, once synced, how long ago it was pushed, kept current
|
||||
// minute by minute.
|
||||
function SyncStatus({ plan }: { plan: Plan }) {
|
||||
// SyncStatus is the plan's status line, in the plan manager's header and
|
||||
// under the Draft board's push button: whether the plan is on Google
|
||||
// Calendar and, once synced, how long ago it was pushed, kept current minute
|
||||
// by minute.
|
||||
function SyncStatus({ plan, className }: { plan: Plan; className?: string }) {
|
||||
const { status, calendar_synced_at: syncedAt } = plan
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
useEffect(() => {
|
||||
@@ -518,7 +558,7 @@ function SyncStatus({ plan }: { plan: Plan }) {
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
return (
|
||||
<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" />}
|
||||
{syncStatuses[status]}
|
||||
{status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}
|
||||
@@ -526,9 +566,32 @@ function SyncStatus({ plan }: { plan: Plan }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Unavailable is an action with nothing behind it yet (Push to Calendar, the
|
||||
// shopping list): it stays focusable, so a tap or focus shows the tooltip
|
||||
// saying why, above it or, at the top of the page, below.
|
||||
// PushFailure says why Push to Calendar failed. A failure only the Google
|
||||
// Calendar settings can fix links there; any other offers to push again.
|
||||
function PushFailure({ error, onRetry }: { error: Error; onRetry: () => void }) {
|
||||
const code = error instanceof ApiError ? error.code : null
|
||||
const settings = code !== null && Object.hasOwn(settingsFailures, code) ? settingsFailures[code] : undefined
|
||||
if (settings !== undefined) {
|
||||
return (
|
||||
<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.{' '}
|
||||
{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({
|
||||
hint,
|
||||
variant = 'primary',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { noteGoogleFailure } from '../settings/calendarApi.ts'
|
||||
|
||||
export const PLANS_PATH = '/api/v1/plans'
|
||||
|
||||
@@ -274,3 +275,26 @@ export function useSwapDay(weekStart: string): SwapDay {
|
||||
dismiss,
|
||||
}
|
||||
}
|
||||
|
||||
// usePushToCalendar pushes the plan of the week starting on weekStart to the
|
||||
// chosen Google calendar (POST /api/v1/plans/{weekStart}/push-calendar). The
|
||||
// server answers with the plan as synced, which replaces the one shown, as a
|
||||
// swap's answer does. A push refused because the plan changed meanwhile
|
||||
// reads the plan again; one Google refused the grant for reads the Google
|
||||
// Calendar connection again, which is now reconnect required.
|
||||
export function usePushToCalendar(weekStart: string) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const key = planKey(weekStart)
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<Plan> => {
|
||||
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: (error) => {
|
||||
if (error instanceof ApiError && error.code === 'plan_changed') void queryClient.invalidateQueries({ queryKey: key })
|
||||
noteGoogleFailure(queryClient, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/* Settings · Google Calendar. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.shared {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.loading {
|
||||
height: 220px;
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
.pickerLoading {
|
||||
height: 72px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text,
|
||||
.hint,
|
||||
.status,
|
||||
.error,
|
||||
.warning {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.status {
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error,
|
||||
.warning {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
/* "Connected · Dinners calendar": teal is the confirmation voice. */
|
||||
.connected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
/* A grant Google refused: the grape banner with Reconnect. */
|
||||
.reconnect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-accent-100);
|
||||
border: 1px solid var(--color-accent-300);
|
||||
color: var(--color-accent-800);
|
||||
}
|
||||
|
||||
.reconnectTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.reconnectText {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* The connect endpoint is a page, so Connect and Reconnect are links drawn
|
||||
as the primary button. */
|
||||
.connect {
|
||||
align-self: flex-start;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { GOOGLE_NOT_CONNECTED } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { CalendarSettingsPage } from './CalendarSettingsPage.tsx'
|
||||
|
||||
const GOOGLE = '/api/v1/integrations/google'
|
||||
const CALENDARS = '/api/v1/integrations/google/calendars'
|
||||
const CALENDAR = '/api/v1/integrations/google/calendar'
|
||||
|
||||
// CONNECTED is a connection pushing to the household's Dinners calendar.
|
||||
const CONNECTED = {
|
||||
status: 'connected',
|
||||
calendar_id: 'dinners@group.calendar.google.com',
|
||||
calendar_name: 'Dinners',
|
||||
connected_at: '2026-10-07T15:00:00Z',
|
||||
last_sync_error: null,
|
||||
}
|
||||
|
||||
// RECONNECT is CONNECTED once Google has refused its grant.
|
||||
const RECONNECT = {
|
||||
...CONNECTED,
|
||||
status: 'reconnect_required',
|
||||
last_sync_error: 'Google access was revoked or expired — reconnect',
|
||||
}
|
||||
|
||||
// CALENDAR_LIST is the account's writable calendars, in Google's order.
|
||||
const CALENDAR_LIST = [
|
||||
{ id: 'sam@example.com', summary: 'sam@example.com', primary: true, accessRole: 'owner' },
|
||||
{ id: 'dinners@group.calendar.google.com', summary: 'Dinners', primary: false, accessRole: 'owner' },
|
||||
{ id: 'family@group.calendar.google.com', summary: 'Family', primary: false, accessRole: 'writer' },
|
||||
]
|
||||
|
||||
type Responder = (body: unknown) => Response | Promise<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()
|
||||
|
||||
expect(await screen.findByRole('region', { name: 'Reconnect Google Calendar' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Connected · Dinners')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a grape banner with Reconnect when reconnect is required, and offers nothing that needs Google', async () => {
|
||||
const fetchMock = stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, RECONNECT) })
|
||||
await renderPage()
|
||||
|
||||
const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' })
|
||||
expect(declaredStyle(banner).background).toBe('var(--color-accent-100)')
|
||||
expect(declaredStyle(banner).color).toBe('var(--color-accent-800)')
|
||||
expect(banner).toHaveTextContent('Google access was revoked or expired — reconnect')
|
||||
expect(banner).toHaveTextContent('Nothing can be pushed to Calendar until you connect again.')
|
||||
expect(within(banner).getByRole('link', { name: 'Reconnect' })).toHaveAttribute(
|
||||
'href',
|
||||
'/api/v1/integrations/google/connect',
|
||||
)
|
||||
// The chosen calendar is kept, but not picked from, nor called connected.
|
||||
expect(screen.getByText('Dinners go to Dinners once Google Calendar is reconnected.')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^Connected/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
expect(reads(fetchMock, CALENDARS)).toBe(0)
|
||||
expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disconnects only once confirmed, then shows Connect Google Calendar', async () => {
|
||||
let status: unknown = CONNECTED
|
||||
const answer = deferred()
|
||||
const fetchMock = stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, status),
|
||||
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
|
||||
[`DELETE ${GOOGLE}`]: () => answer.response,
|
||||
})
|
||||
const queryClient = await renderPage()
|
||||
queryClient.setQueryData(['plans', '2026-10-05'], null)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
|
||||
let dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
|
||||
expect(dialog).toHaveTextContent('Dinners already pushed to Dinners stay on the calendar.')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(sent(fetchMock)).toEqual([])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
|
||||
dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
|
||||
await waitFor(() => expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }]))
|
||||
// While it disconnects the sheet stays, and cannot be dismissed.
|
||||
expect(await within(dialog).findByRole('button', { name: 'Disconnecting…' })).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: 'Keep connected' })).toBeDisabled()
|
||||
|
||||
status = GOOGLE_NOT_CONNECTED
|
||||
answer.send(new Response(null, { status: 204 }))
|
||||
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
// Disconnecting forgets every pushed event, so pushed plans are changed.
|
||||
expect(queryClient.getQueryState(['plans', '2026-10-05'])?.isInvalidated).toBe(true)
|
||||
})
|
||||
|
||||
it('says a disconnect Google refused, keeping the connection', async () => {
|
||||
stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
|
||||
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
|
||||
[`DELETE ${GOOGLE}`]: () => errorResponse(502, 'revoke_failed'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
|
||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Not disconnected: revoke_failed message')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Connected · Dinners')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disconnects a connection that needs reconnecting', async () => {
|
||||
let status: unknown = RECONNECT
|
||||
const fetchMock = stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, status),
|
||||
[`DELETE ${GOOGLE}`]: () => {
|
||||
status = GOOGLE_NOT_CONNECTED
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
|
||||
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
|
||||
expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { CalendarDays, Check, ChevronLeft, RefreshCw, type LucideIcon } from 'lucide-react'
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Select } from '../ui/Select.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import {
|
||||
GOOGLE_CONNECT_PATH,
|
||||
useChooseGoogleCalendar,
|
||||
useDisconnectGoogle,
|
||||
useGoogleCalendars,
|
||||
useGoogleConnection,
|
||||
type GoogleConnection,
|
||||
} from './calendarApi.ts'
|
||||
import styles from './CalendarSettingsPage.module.css'
|
||||
import { Confirm } from './LeaveGuard.tsx'
|
||||
|
||||
// Settings · Google Calendar: connect the household's Google account, choose
|
||||
// the calendar Push to Calendar writes the week's dinners to, and
|
||||
// disconnect. A grant Google has refused shows a grape banner asking to
|
||||
// reconnect, and nothing that needs Google is offered until then.
|
||||
export function CalendarSettingsPage() {
|
||||
const connection = useGoogleConnection()
|
||||
return (
|
||||
<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.
|
||||
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>
|
||||
{error !== null && <p className={styles.reconnectText}>{error}</p>}
|
||||
<p className={styles.reconnectText}>Nothing can be pushed to Calendar until you connect again.</p>
|
||||
<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 { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
@@ -22,7 +22,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
const REGULARS_PATH = '/api/v1/dinners?status=regular'
|
||||
const CALENDAR_PATH = '/api/v1/settings/calendar'
|
||||
const CALENDAR_PATH = '/api/v1/integrations/google'
|
||||
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
|
||||
|
||||
// stubSettings answers as today's server does (see SETTINGS_API), with
|
||||
@@ -96,9 +96,9 @@ describe('SettingsPage', () => {
|
||||
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||
REGULARS_PATH,
|
||||
CALENDAR_PATH,
|
||||
'/api/v1/settings/ai',
|
||||
'/api/v1/settings/ai/models',
|
||||
CALENDAR_PATH,
|
||||
'/api/v1/settings/household',
|
||||
NOTIFICATIONS_PATH,
|
||||
'/api/v1/settings/recipe-search',
|
||||
@@ -109,7 +109,6 @@ describe('SettingsPage', () => {
|
||||
stubSettings()
|
||||
renderSettings()
|
||||
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
|
||||
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
|
||||
expect(
|
||||
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
|
||||
).toBeInTheDocument()
|
||||
@@ -119,7 +118,8 @@ describe('SettingsPage', () => {
|
||||
it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => {
|
||||
stubSettings({
|
||||
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
|
||||
[CALENDAR_PATH]: () => jsonResponse(200, { connected: true, calendar_name: 'Dinners calendar' }),
|
||||
[CALENDAR_PATH]: () =>
|
||||
jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners calendar' }),
|
||||
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }),
|
||||
})
|
||||
renderSettings()
|
||||
@@ -133,7 +133,7 @@ describe('SettingsPage', () => {
|
||||
it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => {
|
||||
stubSettings({
|
||||
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
|
||||
[CALENDAR_PATH]: () => jsonResponse(200, { connected: false }),
|
||||
[CALENDAR_PATH]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }),
|
||||
})
|
||||
renderSettings()
|
||||
@@ -144,6 +144,15 @@ describe('SettingsPage', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['connected with no calendar chosen', { status: 'connected' }, 'Connected · no calendar chosen'],
|
||||
['reconnect required', { status: 'reconnect_required', calendar_id: 'c1', calendar_name: 'Dinners' }, 'Reconnect needed'],
|
||||
])('says Google Calendar is %s', async (_, connection, said) => {
|
||||
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, ...connection }) })
|
||||
renderSettings()
|
||||
expect(await within(row('Google Calendar')).findByText(said)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a status endpoint failure other than a missing endpoint', async () => {
|
||||
stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') })
|
||||
renderSettings()
|
||||
@@ -151,10 +160,10 @@ describe('SettingsPage', () => {
|
||||
expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar')
|
||||
})
|
||||
|
||||
it('refuses a connected calendar without a name', async () => {
|
||||
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { connected: true }) })
|
||||
it('refuses a Google Calendar status it does not know', async () => {
|
||||
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'paused' }) })
|
||||
renderSettings()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a calendar status')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a Google Calendar connection')
|
||||
})
|
||||
|
||||
it('shows another provider and model as the settings name them', async () => {
|
||||
|
||||
@@ -11,16 +11,15 @@ import { Tag } from '../ui/Tag.tsx'
|
||||
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
||||
import {
|
||||
aiStatus,
|
||||
calendarStatus,
|
||||
formatClockTime,
|
||||
notificationsStatus,
|
||||
regularDinnersStatus,
|
||||
useAISettings,
|
||||
useCalendarStatus,
|
||||
useHouseholdSettings,
|
||||
useNotificationsStatus,
|
||||
useRegularDinnersCount,
|
||||
} from './api.ts'
|
||||
import { calendarStatus, useGoogleConnection } from './calendarApi.ts'
|
||||
import styles from './SettingsPage.module.css'
|
||||
import { SettingsTabs } from './SettingsTabs.tsx'
|
||||
|
||||
@@ -39,14 +38,14 @@ export function SettingsPage() {
|
||||
}
|
||||
|
||||
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
||||
// page and shows a live status line. Regular dinners, Google Calendar and
|
||||
// Notifications & Google Chat read endpoints their features have not served
|
||||
// yet; until then each shows its not-yet-available state (see api.ts).
|
||||
// page and shows a live status line. Regular dinners and Notifications &
|
||||
// Google Chat read endpoints their features have not served yet; until then
|
||||
// each shows its not-yet-available state (see api.ts).
|
||||
function SettingsList() {
|
||||
const household = useHouseholdSettings()
|
||||
const ai = useAISettings()
|
||||
const regulars = useRegularDinnersCount()
|
||||
const calendar = useCalendarStatus()
|
||||
const calendar = useGoogleConnection()
|
||||
const notifications = useNotificationsStatus()
|
||||
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { AI_MODELS, AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
@@ -154,7 +154,7 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
||||
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
||||
stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const resize = await renderAt(767)
|
||||
@@ -169,7 +169,7 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
||||
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
|
||||
stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const resize = await renderAt(1180)
|
||||
|
||||
@@ -14,9 +14,6 @@ export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test'
|
||||
// dinners read API. Until it lands the server answers 404 and the Regular
|
||||
// dinners row says the count is unavailable.
|
||||
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
|
||||
// TODO(Feature 16): the Google Calendar integration status. Until Feature 16
|
||||
// serves it the server answers 404, which is the truth: not connected.
|
||||
export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar'
|
||||
// TODO(notifications feature): the Web Push and Google Chat status. Until it
|
||||
// is served the server answers 404, which is the truth: push is off and no
|
||||
// Google Chat space is connected.
|
||||
@@ -516,30 +513,6 @@ export function regularDinnersStatus(count: number | null): string {
|
||||
return `${count} on rotation`
|
||||
}
|
||||
|
||||
// CalendarStatus is the Google Calendar integration status; calendar_name
|
||||
// is the connected calendar's name.
|
||||
export type CalendarStatus = { connected: false } | { connected: true; calendar_name: string }
|
||||
|
||||
export function useCalendarStatus() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: ['settings', 'calendar'],
|
||||
queryFn: () =>
|
||||
fetchUnlessAbsent(apiFetch, CALENDAR_STATUS_PATH, (body): CalendarStatus => {
|
||||
if (isRecord(body) && body.connected === false) return { connected: false }
|
||||
if (isRecord(body) && body.connected === true && typeof body.calendar_name === 'string') {
|
||||
return { connected: true, calendar_name: body.calendar_name }
|
||||
}
|
||||
throw new Error(`GET ${CALENDAR_STATUS_PATH}: response is not a calendar status`)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function calendarStatus(status: CalendarStatus | null): string {
|
||||
if (status === null || !status.connected) return 'Not connected'
|
||||
return `Connected · ${status.calendar_name}`
|
||||
}
|
||||
|
||||
// NotificationsStatus is whether Web Push is on and the connected Google
|
||||
// Chat space's name, null when none is.
|
||||
export type NotificationsStatus = { push: boolean; chat_space: string | null }
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
// GOOGLE_NOT_CONNECTED is a GET /api/v1/integrations/google body: nothing
|
||||
// connected.
|
||||
export const GOOGLE_NOT_CONNECTED = {
|
||||
status: 'not_connected',
|
||||
calendar_id: null,
|
||||
calendar_name: null,
|
||||
connected_at: null,
|
||||
last_sync_error: null,
|
||||
}
|
||||
|
||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||
// today's server does: the dinners, Google Calendar and notifications status
|
||||
// endpoints do not exist yet. The home's sub-tabs stay mounted on mobile, so
|
||||
// today's server does: the dinners and notifications status endpoints do not
|
||||
// exist yet, and Google Calendar is not connected. The home's sub-tabs stay mounted on mobile, so
|
||||
// it also reads the AI models and recipe search settings.
|
||||
export const SETTINGS_API = {
|
||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||
|
||||
Reference in New Issue
Block a user