diff --git a/AGENTS.md b/AGENTS.md index d0e1082..d90cd5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,6 +181,15 @@ Do not put all application state into Zustand. Do not duplicate TanStack Query server state inside Zustand without a specific reason. +### JSX Readability + +Keep JSX focused on describing the rendered UI. Move non-trivial conditions, +calculations, and data transformations into clearly named values in the +component body before the `return`. Simple conditions that remain immediately +understandable may stay inline. Use blank lines to separate distinct groups of +component logic, such as state, derived values, event handlers, and render +conditions. + ## UI Components diff --git a/apps/web/src/features/dashboard/application-shell.test.tsx b/apps/web/src/features/dashboard/application-shell.test.tsx index 6247f80..965a57a 100644 --- a/apps/web/src/features/dashboard/application-shell.test.tsx +++ b/apps/web/src/features/dashboard/application-shell.test.tsx @@ -2,10 +2,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it } from 'vitest'; import { router } from '../../router'; -import { startGuestSession } from '../auth/guest-session'; +import { isGuestSession, startGuestSession } from '../auth/guest-session'; afterEach(() => { cleanup(); @@ -13,7 +14,8 @@ afterEach(() => { }); describe('application shell', () => { - it('renders desktop and mobile navigation with the current page selected', async () => { + it('renders responsive navigation and exits guest mode', async () => { + const user = userEvent.setup(); await router.navigate({ to: '/dashboard' }); startGuestSession(); const queryClient = new QueryClient(); @@ -25,7 +27,7 @@ describe('application shell', () => { expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); expect(screen.getAllByText('Guest')).toHaveLength(2); - expect(screen.getAllByText('Progress is temporary')).toHaveLength(2); + expect(screen.getByText('Progress is temporary')).toBeVisible(); const navigationRegions = screen.getAllByRole('navigation', { name: /primary navigation/i, @@ -46,5 +48,25 @@ describe('application shell', () => { within(navigation).getByRole('link', { name: 'Settings' }), ).toHaveAttribute('href', '/settings'); } + + expect(isGuestSession()).toBe(true); + const profileMenus = screen.getAllByRole('button', { + name: 'Open profile menu', + }); + expect(profileMenus).toHaveLength(2); + expect( + screen.queryByRole('menuitem', { name: 'Exit guest' }), + ).not.toBeInTheDocument(); + + await user.click(profileMenus[0]!); + + const exitAction = screen.getByRole('menuitem', { name: 'Exit guest' }); + + await user.click(exitAction); + + expect(isGuestSession()).toBe(false); + expect( + await screen.findByRole('heading', { name: 'Train with purpose.' }), + ).toBeVisible(); }); }); diff --git a/apps/web/src/features/dashboard/application-shell.tsx b/apps/web/src/features/dashboard/application-shell.tsx index b3f476a..5ff1a54 100644 --- a/apps/web/src/features/dashboard/application-shell.tsx +++ b/apps/web/src/features/dashboard/application-shell.tsx @@ -3,12 +3,13 @@ import { Dumbbell, History, LayoutDashboard, + LogOut, Settings, UserRound, type LucideIcon, } from 'lucide-react'; import { Link } from '@tanstack/react-router'; -import type { ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; import wordmarkUrl from '../../assets/goforlift-wordmark.png'; import { cn } from '../../lib/utils'; @@ -24,11 +25,13 @@ const navigationItems = [ type ApplicationShellProps = { children: ReactNode; identity: ApplicationIdentity; + onExitGuest?: () => void; }; export function ApplicationShell({ children, identity, + onExitGuest, }: ApplicationShellProps) { return (
- +
@@ -56,6 +63,7 @@ export function ApplicationShell({ className="ml-auto max-w-[180px]" compact identity={identity} + onExitGuest={onExitGuest} />
void; }; function IdentitySummary({ className, compact = false, identity, + onExitGuest, }: IdentitySummaryProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const isGuest = identity.kind === 'guest'; const label = isGuest ? 'Guest' @@ -99,33 +111,71 @@ function IdentitySummary({ const detail = isGuest ? 'Progress is temporary' : identity.user.email; const avatarUrl = isGuest ? null : identity.user.avatarUrl; + const shouldShowGuestMenu = + isMenuOpen && isGuest && onExitGuest !== undefined; + return (
- {avatarUrl ? ( - - ) : ( - - - )} -
-

{label}

-

setIsMenuOpen((isOpen) => !isOpen)} + type="button" + > + {avatarUrl ? ( + + ) : ( + + + )} +

+

{label}

+ {!compact && ( +

{detail}

+ )} +
+ + + {shouldShowGuestMenu && ( +
- {detail} -

-
+ +
+ )}
); } diff --git a/apps/web/src/features/dashboard/dashboard-access-boundary.test.tsx b/apps/web/src/features/dashboard/dashboard-access-boundary.test.tsx index ef418b0..2dfbcfa 100644 --- a/apps/web/src/features/dashboard/dashboard-access-boundary.test.tsx +++ b/apps/web/src/features/dashboard/dashboard-access-boundary.test.tsx @@ -38,7 +38,10 @@ describe('dashboard access boundary', () => { await screen.findByRole('heading', { name: 'Dashboard' }), ).toBeVisible(); expect(screen.getAllByText('Go For Lifter')).toHaveLength(2); - expect(screen.getAllByText('lifter@example.com')).toHaveLength(2); + expect(screen.getByText('lifter@example.com')).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'Exit guest' }), + ).not.toBeInTheDocument(); for (const identity of screen.getAllByRole('region', { name: 'Current identity', @@ -62,7 +65,7 @@ describe('dashboard access boundary', () => { await screen.findByRole('heading', { name: 'Dashboard' }), ).toBeVisible(); expect(screen.getAllByText('Guest')).toHaveLength(2); - expect(screen.getAllByText('Progress is temporary')).toHaveLength(2); + expect(screen.getByText('Progress is temporary')).toBeVisible(); }); it('redirects an anonymous visitor to the welcome page', async () => { diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index 1905f27..988962a 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -1,6 +1,7 @@ // Protects and frames every route inside the signed-in or guest application area. -import { createFileRoute, Outlet } from '@tanstack/react-router'; +import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'; +import { clearGuestSession } from '../features/auth/guest-session'; import { ApplicationShell } from '../features/dashboard/application-shell'; import { DashboardAccessBoundary } from '../features/dashboard/dashboard-access-boundary'; @@ -9,10 +10,20 @@ export const Route = createFileRoute('/_app')({ }); function ProtectedApplicationLayout() { + const navigate = useNavigate(); + + function exitGuest() { + clearGuestSession(); + void navigate({ to: '/', replace: true }); + } + return ( {(identity) => ( - + )}