diff --git a/apps/pwa/src/App.test.tsx b/apps/pwa/src/App.test.tsx
index 0acd7aa..496d4ed 100644
--- a/apps/pwa/src/App.test.tsx
+++ b/apps/pwa/src/App.test.tsx
@@ -25,8 +25,17 @@ vi.mock('./components/CalendarShell.js', () => ({
CalendarShell: () =>
CalendarShell
,
}));
+// SetupPage mock respects the `alreadyLocked` prop so the reverse-gate test (gap 5)
+// can distinguish the active wizard (Step 1 "Welcome to FamilySync Setup") from the
+// Surface 8 "Setup already complete" terminal surface. The real SetupPage renders
+// these two surfaces based on this exact prop — see routes/SetupPage.tsx.
vi.mock('./routes/SetupPage.js', () => ({
- SetupPage: () => SetupPage
,
+ SetupPage: ({ alreadyLocked }: { alreadyLocked?: boolean }) =>
+ alreadyLocked ? (
+ Setup already complete
+ ) : (
+ Welcome to FamilySync Setup
+ ),
}));
vi.mock('./components/AppNav.js', () => ({
@@ -177,6 +186,41 @@ describe('App — setup-status gate', () => {
expect(screen.getByTestId('setup-page')).toBeInTheDocument();
});
});
+
+ // gap 5 (T-12-04): manually visiting /setup AFTER setup is complete must show the
+ // "Setup already complete" surface (alreadyLocked), NOT re-mount the active wizard.
+ it('renders the "already complete" surface (not the wizard) on /setup when setupComplete is true', async () => {
+ mockFetchSetupStatus.mockResolvedValue({ setupComplete: true });
+ // Navigate directly to /setup (URL-isolation pattern — beforeEach reset to /)
+ window.history.pushState({}, '', '/setup');
+
+ const queryClient = makeQueryClient();
+ renderApp(queryClient);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('setup-page')).toHaveTextContent('Setup already complete');
+ });
+
+ // The active wizard's Step 1 heading must NOT render when setup is complete
+ expect(screen.queryByText('Welcome to FamilySync Setup')).toBeNull();
+ // Standalone wizard surface — no AppNav shell on /setup
+ expect(screen.queryByTestId('app-nav')).toBeNull();
+ });
+
+ // gap 5 counterpart: /setup with setupComplete false still mounts the active wizard.
+ it('renders the active wizard on /setup when setupComplete is false', async () => {
+ mockFetchSetupStatus.mockResolvedValue({ setupComplete: false });
+ window.history.pushState({}, '', '/setup');
+
+ const queryClient = makeQueryClient();
+ renderApp(queryClient);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('setup-page')).toHaveTextContent('Welcome to FamilySync Setup');
+ });
+
+ expect(screen.queryByText('Setup already complete')).toBeNull();
+ });
});
describe('App — setupStatus and route presence', () => {
diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx
index 1cf1e3f..b641ea5 100644
--- a/apps/pwa/src/App.tsx
+++ b/apps/pwa/src/App.tsx
@@ -81,11 +81,21 @@ export default function App() {
// Fetch current user once at the app shell level so AppNav has member data on
// ALL routes. This is the same query key (['me']) used by CalendarShell, so
// TanStack Query deduplicates the request — no double fetch.
+ //
+ // gap 6 (mechanism (ii) — ['me'] staleness, NOT a linking gap; see SUMMARY):
+ // the first-login claim in upsertUser (auth/user.ts) preserves the same users.id,
+ // so the wizard-stored CalDAV credential stays linked → needsProviderSetup is
+ // correctly FALSE in the DB after the operator authenticates post-wizard. The bug
+ // was purely client-cache: a ['me'] entry populated BEFORE the claim (e.g. an
+ // earlier pre-auth visit) served a stale needsProviderSetup=true for up to 5
+ // minutes, so the "Set up your calendar" banner kept showing. Set staleTime 0 on
+ // the boot ['me'] query so the authenticated app shell always refetches member
+ // status on entry — needsProviderSetup then reflects the just-claimed credential.
const meQuery = useQuery({
queryKey: ['me'],
queryFn: fetchMe,
retry: false,
- staleTime: 5 * 60 * 1000,
+ staleTime: 0,
});
// isAdmin from /api/me — used for UX gating only (D-03). Server enforces 403.
@@ -135,8 +145,26 @@ export default function App() {
return (
- {/* /setup route — standalone wizard, no AppNav/BottomTabBar shell (UI-SPEC §Routing) */}
- } />
+ {/* /setup route — standalone wizard, no AppNav/BottomTabBar shell (UI-SPEC §Routing).
+ Reverse gate (gap 5, T-12-04): once setup is complete the wizard must NOT re-mount.
+ - While setupQuery is loading → render the no-flash placeholder (no wizard before
+ status resolves), mirroring the `*`-route loading gate below.
+ - setupComplete === true → render SetupPage with alreadyLocked → Surface 8
+ ("Setup already complete"), keeping the operator on /setup with a terminal surface.
+ - setupComplete === false (or undefined post-load) → active wizard, as before.
+ The backend already 423s setup mutations; this is the matching frontend reverse-gate. */}
+
+ ) : setupComplete === true ? (
+
+ ) : (
+
+ )
+ }
+ />
{/* All other routes are gated on setup completion */}