Add the Google Calendar settings screen and wire Push to Calendar (Feature 16) #129

2 changed files with 140 additions and 40 deletions
Showing only changes of commit 3a1884de38 - Show all commits
+64 -2
View File
@@ -1,11 +1,12 @@
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { act, render, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan } from './api.ts'
import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushToCalendar, type SwapDay } from './api.ts'
const WEEK = '2026-10-05'
const PLAN = `/api/v1/plans/${WEEK}`
@@ -336,4 +337,65 @@ describe('week mutations', () => {
hook.rerender({ week: WEEK })
expect(weekMutations(queryClient, LATER)).toEqual([])
})
it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => {
const friday = deferred()
const monday = deferred()
const push = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => friday.reply,
[`PUT ${MONDAY}`]: () => monday.reply,
[`POST ${PLAN}/push-calendar`]: () => push.reply,
})
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
let week!: { plan: ReturnType<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') })
})
})
+76 -38
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useMutationState, useQuery, useQueryClient, type Mutation, type QueryClient } from '@tanstack/react-query'
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
@@ -178,42 +178,80 @@ function sameDay(a: PlanDay, b: PlanDay): boolean {
)
}
// WeekMutation is a kind of mutation of a week the mutation cache keeps for
// it: its swaps and its pushes.
type WeekMutation = 'swap' | 'push'
// weekMutation is the kind and week of a swap or push of a week the mutation
// cache keeps for it, or null for any other mutation.
function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null {
const key = mutation.options.mutationKey
if (key === undefined || key.length !== 3 || key[0] !== 'plans' || (key[2] !== 'swap' && key[2] !== 'push')) return null
return { kind: key[2], week: String(key[1]) }
}
// useWeekMutationsKept bounds the mutations of kind the cache keeps. When a
// week is shown, the settled ones of every other week are removed, unless
// that week still has one pending: a pending one, and those an earlier
// pending one must see, stay across leaving its week and coming back. When
// one settles, the same is done, except that a week's latest, when it
// failed, stays until another week is shown, so that coming straight back to
// it shows the failure. The cache holds at most the week shown's, those of
// weeks left pending, and one failure of each week whose last pending one
// settled since the week shown was.
function useWeekMutationsKept(weekStart: string, kind: WeekMutation) {
const queryClient = useQueryClient()
useEffect(() => {
const mutations = queryClient.getMutationCache()
const isKind = (key: readonly unknown[] | undefined) => key !== undefined && key.length === 3 && key[0] === 'plans' && key[2] === kind
function sweep(settled: boolean) {
const left = mutations.findAll({
predicate: ({ options: { mutationKey: key } }) => isKind(key) && key![1] !== weekStart,
})
const pending = new Set(left.filter((m) => m.state.status === 'pending').map((m) => m.options.mutationKey![1]))
const latest = new Map(left.map((m) => [m.options.mutationKey![1], m]))
for (const mutation of left) {
const week = mutation.options.mutationKey![1]
if (pending.has(week)) continue
if (settled && latest.get(week) === mutation && mutation.state.status === 'error') continue
// WeeksShown is what keepWeekMutations is told of the weeks shown: show
// marks a week shown until the function it returns is called.
type WeeksShown = { show: (weekStart: string) => () => void }
const weeksShown = new WeakMap<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 mutations.subscribe((event) => {
if (event.type === 'updated' && isKind(event.mutation.options.mutationKey) && event.mutation.state.status !== 'pending') sweep(true)
})
}, [queryClient, weekStart, kind])
return () => {
const times = shown.get(weekStart)! - 1
if (times === 0) shown.delete(weekStart)
else shown.set(weekStart, times)
}
}
const keeping = { show }
weeksShown.set(queryClient, keeping)
return keeping
}
// useWeekShown marks the week starting on weekStart shown while the
// component is mounted (keepWeekMutations).
function useWeekShown(weekStart: string) {
const queryClient = useQueryClient()
useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart])
}
// swapKey keys the swaps of the week starting on weekStart in the mutation
@@ -246,11 +284,11 @@ export type SwapDay = {
// failure is not the week's. dismiss leaves settled swaps behind; it is
// refused while one is pending, so a later swap is always in the cache for
// an earlier, pending one to see. Settled swaps of a week no longer shown
// are left behind too (useWeekMutationsKept).
// are left behind too (keepWeekMutations).
export function useSwapDay(weekStart: string): SwapDay {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
useWeekMutationsKept(weekStart, 'swap')
useWeekShown(weekStart)
const key = planKey(weekStart)
const mutationKey = swapKey(weekStart)
const mutations = queryClient.getMutationCache()
@@ -267,7 +305,7 @@ export function useSwapDay(weekStart: string): SwapDay {
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
mutationKey,
// Kept until dismissed or settled in a week not shown
// (useWeekMutationsKept): a later swap must stay in the cache while an
// (keepWeekMutations): a later swap must stay in the cache while an
// earlier one is pending.
gcTime: Infinity,
mutationFn: async ({ date, dinner }) => {
@@ -346,11 +384,11 @@ export type PushToCalendar = {
// swaps do, so leaving the week and coming back keeps one pending. reset
// leaves settled pushes behind; it is refused while one is pending. Settled
// pushes of a week no longer shown are left behind too
// (useWeekMutationsKept).
// (keepWeekMutations).
export function usePushToCalendar(weekStart: string): PushToCalendar {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
useWeekMutationsKept(weekStart, 'push')
useWeekShown(weekStart)
const key = planKey(weekStart)
const mutationKey = pushKey(weekStart)
const mutations = queryClient.getMutationCache()
@@ -364,7 +402,7 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
function mutate() {
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
mutationKey,
// Kept until reset or settled in a week not shown (useWeekMutationsKept):
// Kept until reset or settled in a week not shown (keepWeekMutations):
// a push pending in a week left must still be found when it is shown
// again.
gcTime: Infinity,