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 (
+
+ );
+}
+
+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}
+
+ )}
+
+
+
+ {routine.exerciseCount} {exerciseLabel}
+
+ {schedule && (
+
+
+ {schedule}
+
+ )}
+
+
+ );
+}
+
+type RoutineMessageProps = {
+ detail: string;
+ title: string;
+ action?: ReactNode;
+};
+
+function RoutineMessage({ detail, title, action }: RoutineMessageProps) {
+ return (
+
+
+
{title}
+
{detail}
+ {action &&
{action}
}
+
+ );
+}
+
+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: {