From 7f660dc4db63caa07687623a5529dafdceed1f86 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Fri, 28 Aug 2026 13:31:03 -0700 Subject: [PATCH] feat(web): add routine editor shell --- .gitignore | 1 + .../src/components/ui/add-action-button.tsx | 23 ++++++ apps/web/src/components/ui/input.tsx | 25 ++++++ apps/web/src/components/ui/label.tsx | 43 +++++++++++ apps/web/src/components/ui/page-title.tsx | 13 ++++ .../features/routines/routine-editor.test.tsx | 52 +++++++++++++ .../src/features/routines/routine-editor.tsx | 76 +++++++++++++++++++ .../routines/routine-schedule-field.tsx | 26 +++++++ .../routines/routines-landing.test.tsx | 34 ++++++--- .../features/routines/routines-landing.tsx | 40 +++++----- apps/web/src/routeTree.gen.ts | 35 ++++++++- apps/web/src/routes/_app.routines_.new.tsx | 8 ++ 12 files changed, 342 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/ui/add-action-button.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/label.tsx create mode 100644 apps/web/src/components/ui/page-title.tsx create mode 100644 apps/web/src/features/routines/routine-editor.test.tsx create mode 100644 apps/web/src/features/routines/routine-editor.tsx create mode 100644 apps/web/src/features/routines/routine-schedule-field.tsx create mode 100644 apps/web/src/routes/_app.routines_.new.tsx diff --git a/.gitignore b/.gitignore index 5f053bd..281f8fa 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ coverage/ Thumbs.db .idea/ .vscode/ +.tanstack/ diff --git a/apps/web/src/components/ui/add-action-button.tsx b/apps/web/src/components/ui/add-action-button.tsx new file mode 100644 index 0000000..007cc52 --- /dev/null +++ b/apps/web/src/components/ui/add-action-button.tsx @@ -0,0 +1,23 @@ +// Provides the consistent full-width ghost-button treatment for adding items. +import type { ComponentProps } from 'react'; + +import { cn } from '../../lib/utils'; +import { Button } from './button'; + +type AddActionButtonProps = Omit, 'variant'>; + +export function AddActionButton({ className, ...props }: AddActionButtonProps) { + return ( +
+
+ ); +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 0000000..4be12a4 --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +// Provides the shared visual and accessibility foundation for text inputs. +import type { ComponentProps } from 'react'; + +import { cn } from '../../lib/utils'; + +export function Input({ + className, + type = 'text', + ...props +}: ComponentProps<'input'>) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 0000000..0b408a8 --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,43 @@ +// Provides consistent typography and accessibility styling for form labels. +import type { ComponentProps } from 'react'; +import { Slot } from 'radix-ui'; + +import { cn } from '../../lib/utils'; + +type LabelProps = ComponentProps<'label'> & { + asChild?: boolean; + isOptional?: boolean; +}; + +export function Label({ + asChild = false, + children, + className, + isOptional = false, + ...props +}: LabelProps) { + const labelClassName = cn( + 'text-[15px] font-normal text-foreground', + className, + ); + + if (asChild) { + return ( + + {children} + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/ui/page-title.tsx b/apps/web/src/components/ui/page-title.tsx new file mode 100644 index 0000000..58db97b --- /dev/null +++ b/apps/web/src/components/ui/page-title.tsx @@ -0,0 +1,13 @@ +// Provides consistent styling for the primary title shown on application pages. +import type { ComponentProps } from 'react'; + +import { cn } from '../../lib/utils'; + +export function PageTitle({ className, ...props }: ComponentProps<'h1'>) { + return ( +

+ ); +} diff --git a/apps/web/src/features/routines/routine-editor.test.tsx b/apps/web/src/features/routines/routine-editor.test.tsx new file mode 100644 index 0000000..cbf6a80 --- /dev/null +++ b/apps/web/src/features/routines/routine-editor.test.tsx @@ -0,0 +1,52 @@ +/** Verifies the routine editor shell exposes its initial responsive workflow. */ +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { RoutineEditor } from './routine-editor'; + +afterEach(cleanup); + +describe('routine editor', () => { + it('shows the initial editor shell without enabling unfinished actions', async () => { + renderEditor(); + + expect( + await screen.findByRole('heading', { name: 'Create Routine' }), + ).toBeVisible(); + expect(screen.getByLabelText('Routine Name')).toHaveAttribute( + 'placeholder', + 'e.g., Upper Body', + ); + expect( + screen.getByRole('heading', { name: 'Exercises (0)' }), + ).toBeVisible(); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add Exercise' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add schedule' })).toBeDisabled(); + expect( + screen.getByRole('link', { name: 'Back to routines' }), + ).toHaveAttribute('href', '/routines'); + }); +}); + +function renderEditor() { + const rootRoute = createRootRoute(); + const editorRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/routines/new', + component: RoutineEditor, + }); + const testRouter = createRouter({ + history: createMemoryHistory({ initialEntries: ['/routines/new'] }), + routeTree: rootRoute.addChildren([editorRoute]), + }); + + return render(); +} diff --git a/apps/web/src/features/routines/routine-editor.tsx b/apps/web/src/features/routines/routine-editor.tsx new file mode 100644 index 0000000..e306fd4 --- /dev/null +++ b/apps/web/src/features/routines/routine-editor.tsx @@ -0,0 +1,76 @@ +/** Presents the responsive visual shell for creating a registered-user routine. */ +import { Link } from '@tanstack/react-router'; +import { ArrowLeft, Plus } from 'lucide-react'; + +import { AddActionButton } from '../../components/ui/add-action-button'; +import { Button } from '../../components/ui/button'; +import { Input } from '../../components/ui/input'; +import { Label } from '../../components/ui/label'; +import { PageTitle } from '../../components/ui/page-title'; +import { RoutineScheduleField } from './routine-schedule-field'; + +export function RoutineEditor() { + return ( +
+ + +
+
+ + +
+ + +
+
+ ); +} + +function EditorHeader() { + return ( +
+ + Create Routine +
+ + +
+
+ ); +} + +function RoutineDetails() { + return ( +
+ + +
+ ); +} + +function ExerciseSection() { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/features/routines/routine-schedule-field.tsx b/apps/web/src/features/routines/routine-schedule-field.tsx new file mode 100644 index 0000000..3054ee6 --- /dev/null +++ b/apps/web/src/features/routines/routine-schedule-field.tsx @@ -0,0 +1,26 @@ +/** Displays routine schedule selections and the entry point for adding one. */ +import { Plus } from 'lucide-react'; + +import { Button } from '../../components/ui/button'; +import { Label } from '../../components/ui/label'; + +export function RoutineScheduleField() { + return ( +
+ + +
+ +
+
+ ); +} diff --git a/apps/web/src/features/routines/routines-landing.test.tsx b/apps/web/src/features/routines/routines-landing.test.tsx index 01c74e2..8574f69 100644 --- a/apps/web/src/features/routines/routines-landing.test.tsx +++ b/apps/web/src/features/routines/routines-landing.test.tsx @@ -1,5 +1,11 @@ /** Verifies the routine page's registered-user and guest-facing states. */ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router'; import { cleanup, fireEvent, @@ -29,10 +35,16 @@ function createWrapper(identity: 'guest' | 'user' = 'user') { : ({ kind: 'user', user: createPublicUser() } as const); return function Wrapper({ children }: PropsWithChildren) { + const rootRoute = createRootRoute({ component: () => children }); + const testRouter = createRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + routeTree: rootRoute, + }); + return ( - {children} + ); @@ -40,7 +52,7 @@ function createWrapper(identity: 'guest' | 'user' = 'user') { } describe('routines landing', () => { - it('shows a localized loading state', () => { + it('shows a localized loading state', async () => { vi.stubGlobal( 'fetch', vi.fn(() => new Promise(() => {})), @@ -48,10 +60,10 @@ describe('routines landing', () => { render(, { wrapper: createWrapper() }); - expect(screen.getByLabelText('Loading routines')).toBeVisible(); + expect(await screen.findByLabelText('Loading routines')).toBeVisible(); }); - it('shows the documented empty state', async () => { + it('shows only the add-routine action for an empty routine list', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(Response.json({ routines: [] })), @@ -59,10 +71,10 @@ describe('routines landing', () => { render(, { wrapper: createWrapper() }); - expect(await screen.findByText('No routines yet.')).toBeVisible(); expect( - screen.getByText('Create your first routine to get ready for liftoff.'), + await screen.findByRole('link', { name: 'Add Routine' }), ).toBeVisible(); + expect(screen.queryByText('No routines yet.')).not.toBeInTheDocument(); }); it('shows routine summaries returned by the API', async () => { @@ -106,16 +118,20 @@ describe('routines landing', () => { fireEvent.click(await screen.findByRole('button', { name: 'Try again' })); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); - expect(await screen.findByText('No routines yet.')).toBeVisible(); + expect( + await screen.findByRole('link', { name: 'Add Routine' }), + ).toBeVisible(); }); - it('does not request persistent routines for a guest', () => { + it('does not request persistent routines for a guest', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); render(, { wrapper: createWrapper('guest') }); - expect(screen.getByText('Guest routines are coming later.')).toBeVisible(); + expect( + await screen.findByText('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 index 5610bf2..cd7ea93 100644 --- a/apps/web/src/features/routines/routines-landing.tsx +++ b/apps/web/src/features/routines/routines-landing.tsx @@ -1,42 +1,34 @@ /** 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 { Link } from '@tanstack/react-router'; +import { CalendarDays, Dumbbell, Plus } from 'lucide-react'; import type { ReactNode } from 'react'; import { useApplicationIdentity } from '../../app/application-identity'; +import { AddActionButton } from '../../components/ui/add-action-button'; +import { PageTitle } from '../../components/ui/page-title'; import { useRoutines } from './routines.query'; const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; export function RoutinesLanding() { const identity = useApplicationIdentity(); + const isRegisteredUser = identity.kind === 'user'; - const routineContent = - identity.kind === 'guest' ? ( - - ) : ( - - ); + const routineContent = !isRegisteredUser ? ( + + ) : ( + + ); return (
- + Routines {routineContent}
); } -function RoutinesHeader() { - return ( -
-

Training plan

-

- Routines -

-
- ); -} - function GuestRoutinesMessage() { return ( unknown }) { function EmptyRoutineList() { return ( - + + + ); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7f25a46..6523e83 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as AppDashboardRouteImport } from './routes/_app.dashboard' import { Route as AppHistoryRouteImport } from './routes/_app.history' import { Route as AppRoutinesRouteImport } from './routes/_app.routines' import { Route as AppSettingsRouteImport } from './routes/_app.settings' +import { Route as AppRoutinesNewRouteImport } from './routes/_app.routines_.new' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -45,6 +46,11 @@ const AppSettingsRoute = AppSettingsRouteImport.update({ path: '/settings', getParentRoute: () => AppRoute, } as any) +const AppRoutinesNewRoute = AppRoutinesNewRouteImport.update({ + id: '/routines_/new', + path: '/routines/new', + getParentRoute: () => AppRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -52,6 +58,7 @@ export interface FileRoutesByFullPath { '/history': typeof AppHistoryRoute '/routines': typeof AppRoutinesRoute '/settings': typeof AppSettingsRoute + '/routines/new': typeof AppRoutinesNewRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -59,6 +66,7 @@ export interface FileRoutesByTo { '/history': typeof AppHistoryRoute '/routines': typeof AppRoutinesRoute '/settings': typeof AppSettingsRoute + '/routines/new': typeof AppRoutinesNewRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -68,12 +76,25 @@ export interface FileRoutesById { '/_app/history': typeof AppHistoryRoute '/_app/routines': typeof AppRoutinesRoute '/_app/settings': typeof AppSettingsRoute + '/_app/routines_/new': typeof AppRoutinesNewRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/dashboard' | '/history' | '/routines' | '/settings' + fullPaths: + | '/' + | '/dashboard' + | '/history' + | '/routines' + | '/settings' + | '/routines/new' fileRoutesByTo: FileRoutesByTo - to: '/' | '/dashboard' | '/history' | '/routines' | '/settings' + to: + | '/' + | '/dashboard' + | '/history' + | '/routines' + | '/settings' + | '/routines/new' id: | '__root__' | '/' @@ -82,6 +103,7 @@ export interface FileRouteTypes { | '/_app/history' | '/_app/routines' | '/_app/settings' + | '/_app/routines_/new' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -133,6 +155,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppSettingsRouteImport parentRoute: typeof AppRoute } + '/_app/routines_/new': { + id: '/_app/routines_/new' + path: '/routines/new' + fullPath: '/routines/new' + preLoaderRoute: typeof AppRoutinesNewRouteImport + parentRoute: typeof AppRoute + } } } @@ -141,6 +170,7 @@ interface AppRouteChildren { AppHistoryRoute: typeof AppHistoryRoute AppRoutinesRoute: typeof AppRoutinesRoute AppSettingsRoute: typeof AppSettingsRoute + AppRoutinesNewRoute: typeof AppRoutinesNewRoute } const AppRouteChildren: AppRouteChildren = { @@ -148,6 +178,7 @@ const AppRouteChildren: AppRouteChildren = { AppHistoryRoute: AppHistoryRoute, AppRoutinesRoute: AppRoutinesRoute, AppSettingsRoute: AppSettingsRoute, + AppRoutinesNewRoute: AppRoutinesNewRoute, } const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) diff --git a/apps/web/src/routes/_app.routines_.new.tsx b/apps/web/src/routes/_app.routines_.new.tsx new file mode 100644 index 0000000..ef30a47 --- /dev/null +++ b/apps/web/src/routes/_app.routines_.new.tsx @@ -0,0 +1,8 @@ +// Maps the routine-creation URL into the protected application layout. +import { createFileRoute } from '@tanstack/react-router'; + +import { RoutineEditor } from '../features/routines/routine-editor'; + +export const Route = createFileRoute('/_app/routines_/new')({ + component: RoutineEditor, +});