From 1cc5b4f6abd37a9f0e62395db682930ce8cccbd2 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 10 Oct 2026 16:31:04 -0400 Subject: [PATCH] Refetch Rotation checks when a notification focuses Home already open (Feature 21) A notification tap that focuses a window already at its url does not navigate, so Home does not remount and a recently read empty list stays within the query client's staleTime. The service worker now posts that window NotificationOpened, and useRotationReviews refetches on it, so the Keep it in rotation card shows without a remount. Co-Authored-By: Claude Opus 5.5 --- web/src/home/RotationCheck.test.tsx | 38 +++++++++++++++++++++++++++-- web/src/home/rotation.ts | 21 +++++++++++++--- web/src/push/notification.test.ts | 14 +++++++++++ web/src/push/notification.ts | 16 +++++++++++- 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/web/src/home/RotationCheck.test.tsx b/web/src/home/RotationCheck.test.tsx index 927df88..152498e 100644 --- a/web/src/home/RotationCheck.test.tsx +++ b/web/src/home/RotationCheck.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { createMemoryRouter, RouterProvider } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' -import { clickPath, parsePushPayload } from '../push/notification.ts' +import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts' import { QueryClientProvider } from '@tanstack/react-query' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' @@ -19,6 +19,7 @@ const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date afterEach(() => { vi.unstubAllGlobals() + Reflect.deleteProperty(navigator, 'serviceWorker') }) type Deferred = { resolve: (response: Response) => void; path: string; body: unknown } @@ -53,7 +54,7 @@ function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) { throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`) }) vi.stubGlobal('fetch', fetchMock) - return { fetchMock, decisions } + return { fetchMock, decisions, server } } // decided is the server's answer to a decision on review. @@ -244,4 +245,37 @@ describe('RotationCheck', () => { await renderHome(path) expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument() }) + + it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => { + // navigator.serviceWorker is where the worker's postMessage to this + // window arrives. + const container = new EventTarget() + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container }) + const { fetchMock, server } = stubApi([]) + const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length + await renderHome('/') + const home = await screen.findByRole('heading', { name: 'Pizza night' }) + await waitFor(() => expect(reads()).toBe(1)) + expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument() + + // Within the cached list's staleTime the Rotation check comes due and + // its notification is tapped while Home is open at /. + server.push(SHAKSHUKA) + const origin = 'https://mealquest.test' + const win = { + url: `${origin}/`, + focused: true, + focus: vi.fn(async () => {}), + navigate: vi.fn(async () => {}), + postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })), + } satisfies ClickWindow + await act(() => + openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin), + ) + + expect(win.navigate).not.toHaveBeenCalled() + expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument() + expect(reads()).toBe(2) + expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home) + }) }) diff --git a/web/src/home/rotation.ts b/web/src/home/rotation.ts index ac5416c..27375ae 100644 --- a/web/src/home/rotation.ts +++ b/web/src/home/rotation.ts @@ -1,7 +1,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { photoId } from '../dinners/api.ts' +import { isNotificationOpened } from '../push/notification.ts' export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews' export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const @@ -43,16 +45,29 @@ export function parseRotationReviews(where: string, body: unknown): RotationRevi // useRotationReviews is the pending Rotation checks (GET // /api/v1/rotation-reviews), oldest plan date first. Each mount of Home -// reads them afresh, so Home opened from a rotation_review notification -// shows the check it is about. +// reads them afresh, and so does a notification tap that focuses Home +// already open (the service worker's NotificationOpened), so Home opened +// from a rotation_review notification shows the check it is about. export function useRotationReviews() { const { apiFetch } = useApi() - return useQuery({ + const query = useQuery({ queryKey: ROTATION_REVIEWS_KEY, queryFn: async () => parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch(ROTATION_REVIEWS_PATH)), refetchOnMount: 'always', }) + const { refetch } = query + useEffect(() => { + // Without a service worker there are no notifications to tap. + if (!('serviceWorker' in navigator)) return + const container = navigator.serviceWorker + const onMessage = (event: MessageEvent) => { + if (isNotificationOpened(event.data)) void refetch() + } + container.addEventListener('message', onMessage) + return () => container.removeEventListener('message', onMessage) + }, [refetch]) + return query } // useDecideRotationReview answers "Keep it in rotation?" for a pending diff --git a/web/src/push/notification.test.ts b/web/src/push/notification.test.ts index 2c432ae..c7061b6 100644 --- a/web/src/push/notification.test.ts +++ b/web/src/push/notification.test.ts @@ -5,6 +5,7 @@ import { GENERIC_NOTICE, appPath, clickPath, + isNotificationOpened, openNotification, parsePushPayload, planClick, @@ -178,6 +179,7 @@ describe('openNotification', () => { focused, focus: vi.fn(async () => {}), navigate: vi.fn(async () => {}), + postMessage: vi.fn(), } satisfies ClickWindow } @@ -193,6 +195,18 @@ describe('openNotification', () => { expect(win.focus).toHaveBeenCalled() expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`) expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!) + expect(win.postMessage).not.toHaveBeenCalled() + expect(all.openWindow).not.toHaveBeenCalled() + }) + + it('focuses a window already at the url and tells it NotificationOpened instead of navigating', async () => { + const win = window('/', true) + const all = clients([win]) + await openNotification({ url: '/' }, all, origin) + expect(win.focus).toHaveBeenCalled() + expect(win.navigate).not.toHaveBeenCalled() + expect(win.postMessage).toHaveBeenCalledWith({ type: 'NOTIFICATION_OPENED', url: `${origin}/` }) + expect(isNotificationOpened(win.postMessage.mock.calls[0]![0])).toBe(true) expect(all.openWindow).not.toHaveBeenCalled() }) diff --git a/web/src/push/notification.ts b/web/src/push/notification.ts index b86c9c4..b77daf6 100644 --- a/web/src/push/notification.ts +++ b/web/src/push/notification.ts @@ -150,6 +150,18 @@ export function planClick(windows: readonly W[], path: stri export type ClickWindow = AppWindow & { focus(): Promise navigate(url: string): Promise + postMessage(message: NotificationOpened): void +} + +// NotificationOpened is the message a tap posts to an open window it focuses +// without navigating, because the window is already at url: nothing remounts +// there, so the screen reads afresh what the notification is about. +export type NotificationOpened = { type: 'NOTIFICATION_OPENED'; url: string } + +// isNotificationOpened reports whether a message from the service worker is +// NotificationOpened. +export function isNotificationOpened(message: unknown): message is NotificationOpened { + return isRecord(message) && message.type === 'NOTIFICATION_OPENED' && typeof message.url === 'string' } export type ClickClients = { @@ -158,7 +170,8 @@ export type ClickClients = { } // openNotification carries out a tap on a notification with data: focus -// first, while the tap still allows it, then navigate. +// first, while the tap still allows it, then navigate; a window already at +// the url is told NotificationOpened instead. export async function openNotification( data: unknown, clients: ClickClients, @@ -171,4 +184,5 @@ export async function openNotification( } await plan.window.focus() if (plan.navigate) await plan.window.navigate(plan.url) + else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url }) }