diff --git a/apps/web/src/features/routines/routines-landing.test.tsx b/apps/web/src/features/routines/routines-landing.test.tsx new file mode 100644 index 0000000..01c74e2 --- /dev/null +++ b/apps/web/src/features/routines/routines-landing.test.tsx @@ -0,0 +1,121 @@ +/** Verifies the routine page's registered-user and guest-facing states. */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import type { PropsWithChildren } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ApplicationIdentityContext } from '../../app/application-identity'; +import { createPublicUser } from '../../test/fixtures/public-user.fixture'; +import { RoutinesLanding } from './routines-landing'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function createWrapper(identity: 'guest' | 'user' = 'user') { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const identityValue = + identity === 'guest' + ? ({ kind: 'guest' } as const) + : ({ kind: 'user', user: createPublicUser() } as const); + + return function Wrapper({ children }: PropsWithChildren) { + return ( + + + {children} + + + ); + }; +} + +describe('routines landing', () => { + it('shows a localized loading state', () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => {})), + ); + + render(, { wrapper: createWrapper() }); + + expect(screen.getByLabelText('Loading routines')).toBeVisible(); + }); + + it('shows the documented empty state', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ routines: [] })), + ); + + render(, { wrapper: createWrapper() }); + + expect(await screen.findByText('No routines yet.')).toBeVisible(); + expect( + screen.getByText('Create your first routine to get ready for liftoff.'), + ).toBeVisible(); + }); + + it('shows routine summaries returned by the API', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json({ + routines: [ + { + id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + name: 'Upper Body', + description: 'Monday strength session', + exerciseCount: 3, + scheduledDays: [1, 3, 5], + createdAt: '2026-08-26T10:00:00.000Z', + updatedAt: '2026-08-26T10:00:00.000Z', + }, + ], + }), + ), + ); + + render(, { wrapper: createWrapper() }); + + expect( + await screen.findByRole('heading', { name: 'Upper Body' }), + ).toBeVisible(); + expect(screen.getByText('3 exercises')).toBeVisible(); + expect(screen.getByText('Mon, Wed, Fri')).toBeVisible(); + }); + + it('shows a retry action after an API failure', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 500 })) + .mockResolvedValueOnce(Response.json({ routines: [] })); + vi.stubGlobal('fetch', fetchMock); + + render(, { wrapper: createWrapper() }); + + fireEvent.click(await screen.findByRole('button', { name: 'Try again' })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(await screen.findByText('No routines yet.')).toBeVisible(); + }); + + it('does not request persistent routines for a guest', () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + render(, { wrapper: createWrapper('guest') }); + + expect(screen.getByText('Guest routines are coming later.')).toBeVisible(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/features/routines/routines-landing.tsx b/apps/web/src/features/routines/routines-landing.tsx new file mode 100644 index 0000000..5610bf2 --- /dev/null +++ b/apps/web/src/features/routines/routines-landing.tsx @@ -0,0 +1,172 @@ +/** Displays the routine collection with intentional guest, loading, error, and empty states. */ +import type { RoutineSummary } from '@goforlift/contracts'; +import { CalendarDays, Dumbbell } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import { useApplicationIdentity } from '../../app/application-identity'; +import { useRoutines } from './routines.query'; + +const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export function RoutinesLanding() { + const identity = useApplicationIdentity(); + + const routineContent = + identity.kind === 'guest' ? ( + + ) : ( + + ); + + return ( +
+ + {routineContent} +
+ ); +} + +function RoutinesHeader() { + return ( +
+

Training plan

+

+ Routines +

+
+ ); +} + +function GuestRoutinesMessage() { + return ( + + ); +} + +function RegisteredUserRoutines() { + const routinesQuery = useRoutines(); + + if (routinesQuery.isPending) { + return ; + } + + if (routinesQuery.isError) { + return ; + } + + if (routinesQuery.data.routines.length === 0) { + return ; + } + + return ; +} + +function RoutineLoadError({ onRetry }: { onRetry: () => unknown }) { + const retryButton = ( + + ); + + return ( + + ); +} + +function EmptyRoutineList() { + return ( + + ); +} + +type RoutineListProps = { + routines: RoutineSummary[]; +}; + +function RoutineList({ routines }: RoutineListProps) { + return ( +
    + {routines.map((routine) => ( + + ))} +
+ ); +} + +type RoutineCardProps = { + routine: RoutineSummary; +}; + +function RoutineCard({ routine }: RoutineCardProps) { + const schedule = routine.scheduledDays + .map((day) => DAY_LABELS[day]) + .join(', '); + const exerciseLabel = routine.exerciseCount === 1 ? 'exercise' : 'exercises'; + + return ( +
  • +

    {routine.name}

    + {routine.description && ( +

    + {routine.description} +

    + )} +
    + + + {schedule && ( + + + )} +
    +
  • + ); +} + +type RoutineMessageProps = { + detail: string; + title: string; + action?: ReactNode; +}; + +function RoutineMessage({ detail, title, action }: RoutineMessageProps) { + return ( +
    +
    + ); +} + +function RoutineListSkeleton() { + return ( +
    + {[0, 1].map((item) => ( +
    + ))} +
    + ); +} diff --git a/apps/web/src/features/routines/routines.api.test.ts b/apps/web/src/features/routines/routines.api.test.ts new file mode 100644 index 0000000..61482fd --- /dev/null +++ b/apps/web/src/features/routines/routines.api.test.ts @@ -0,0 +1,56 @@ +/** Verifies routine requests include credentials and validate backend responses. */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { fetchRoutines, RoutinesApiError } from './routines.api'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('fetchRoutines', () => { + it('fetches and validates the authenticated user routines', async () => { + const response = { + routines: [ + { + id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + name: 'Upper Body', + description: null, + exerciseCount: 3, + scheduledDays: [1, 3, 5], + createdAt: '2026-08-26T10:00:00.000Z', + updatedAt: '2026-08-26T10:00:00.000Z', + }, + ], + }; + const fetchMock = vi.fn().mockResolvedValue(Response.json(response)); + vi.stubGlobal('fetch', fetchMock); + + const abortController = new AbortController(); + + await expect(fetchRoutines(abortController.signal)).resolves.toEqual( + response, + ); + expect(fetchMock).toHaveBeenCalledWith('/api/routines', { + credentials: 'include', + signal: abortController.signal, + }); + }); + + it('rejects an unsuccessful response with its status', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response(null, { status: 500 })), + ); + + await expect(fetchRoutines()).rejects.toEqual(new RoutinesApiError(500)); + }); + + it('rejects a response that violates the shared contract', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(Response.json({ routines: [{ id: 'unsafe' }] })), + ); + + await expect(fetchRoutines()).rejects.toThrow(); + }); +}); diff --git a/apps/web/src/features/routines/routines.api.ts b/apps/web/src/features/routines/routines.api.ts new file mode 100644 index 0000000..5c56dde --- /dev/null +++ b/apps/web/src/features/routines/routines.api.ts @@ -0,0 +1,28 @@ +/** Fetches and validates browser-safe routine data from the backend API. */ +import { + routineListResponseSchema, + type RoutineListResponse, +} from '@goforlift/contracts'; + +export class RoutinesApiError extends Error { + constructor(public readonly status: number) { + super(`Routines API request failed with status ${status}`); + this.name = 'RoutinesApiError'; + } +} + +export async function fetchRoutines( + signal?: AbortSignal, +): Promise { + const response = await fetch('/api/routines', { + credentials: 'include', + signal, + }); + + if (!response.ok) { + throw new RoutinesApiError(response.status); + } + + const data: unknown = await response.json(); + return routineListResponseSchema.parse(data); +} diff --git a/apps/web/src/features/routines/routines.query.ts b/apps/web/src/features/routines/routines.query.ts new file mode 100644 index 0000000..444cd85 --- /dev/null +++ b/apps/web/src/features/routines/routines.query.ts @@ -0,0 +1,16 @@ +/** Provides the TanStack Query source for the authenticated user's routines. */ +import { queryOptions, useQuery } from '@tanstack/react-query'; + +import { fetchRoutines } from './routines.api'; + +export const routinesQueryKey = ['routines'] as const; + +export const routinesQueryOptions = queryOptions({ + queryKey: routinesQueryKey, + queryFn: ({ signal }) => fetchRoutines(signal), + retry: false, +}); + +export function useRoutines(enabled = true) { + return useQuery({ ...routinesQueryOptions, enabled }); +} diff --git a/apps/web/src/routes/_app.routines.tsx b/apps/web/src/routes/_app.routines.tsx index 87f2a35..5be1bad 100644 --- a/apps/web/src/routes/_app.routines.tsx +++ b/apps/web/src/routes/_app.routines.tsx @@ -1,14 +1,8 @@ // Maps the routines URL into the protected application layout. import { createFileRoute } from '@tanstack/react-router'; +import { RoutinesLanding } from '../features/routines/routines-landing'; + export const Route = createFileRoute('/_app/routines')({ component: RoutinesLanding, }); - -function RoutinesLanding() { - return ( -

    - Routines -

    - ); -} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 15720db..fc1eb02 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -20,10 +20,17 @@ export default defineConfig({ '@': path.resolve(import.meta.dirname, './src'), }, }, + server: { proxy: { '/auth': 'http://localhost:3000', '/health': 'http://localhost:3000', + // Keep API requests distinct from React page URLs; Vite removes the + // frontend-only /api prefix before forwarding to Express on port 3000. + '/api': { + target: 'http://localhost:3000', + rewrite: (requestPath) => requestPath.replace(/^\/api/, ''), + }, }, }, test: {