Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ coverage/
Thumbs.db
.idea/
.vscode/
.tanstack/
23 changes: 23 additions & 0 deletions apps/web/src/components/ui/add-action-button.tsx
Original file line number Diff line number Diff line change
@@ -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<ComponentProps<typeof Button>, 'variant'>;

export function AddActionButton({ className, ...props }: AddActionButtonProps) {
return (
<div className="group">
<Button
className={cn(
'h-14 w-full text-primary disabled:opacity-100',
'group-hover:bg-accent group-hover:text-accent-foreground',
className,
)}
variant="ghost"
{...props}
/>
</div>
);
}
25 changes: 25 additions & 0 deletions apps/web/src/components/ui/input.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<input
className={cn(
'h-12 w-full rounded-md border border-input bg-surface-elevated px-4',
'text-base text-foreground shadow-sm outline-none',
'placeholder:text-muted-foreground',
'focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
type={type}
{...props}
/>
);
}
43 changes: 43 additions & 0 deletions apps/web/src/components/ui/label.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Slot.Root className={labelClassName} {...props}>
{children}
</Slot.Root>
);
}

return (
<label className={labelClassName} {...props}>
{children}
{isOptional && (
<>
{' '}
<span className="text-muted-foreground">(optional)</span>
</>
)}
</label>
);
}
13 changes: 13 additions & 0 deletions apps/web/src/components/ui/page-title.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<h1
className={cn('text-xl font-semibold sm:text-2xl', className)}
{...props}
/>
);
}
52 changes: 52 additions & 0 deletions apps/web/src/features/routines/routine-editor.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RouterProvider router={testRouter} />);
}
76 changes: 76 additions & 0 deletions apps/web/src/features/routines/routine-editor.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="mx-auto w-full max-w-5xl">
<EditorHeader />

<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
<RoutineDetails />
<ExerciseSection />
</div>

<RoutineScheduleField />
</div>
</section>
);
}

function EditorHeader() {
return (
<header className="mb-7 flex items-center gap-3 border-b pb-5">
<Button aria-label="Back to routines" asChild size="icon" variant="ghost">
<Link to="/routines">
<ArrowLeft aria-hidden="true" />
</Link>
</Button>
<PageTitle>Create Routine</PageTitle>
<div className="ml-auto flex gap-2">
<Button className="hidden sm:inline-flex" disabled variant="outline">
Preview
</Button>
<Button disabled>Save</Button>
</div>
</header>
);
}

function RoutineDetails() {
return (
<div>
<Label htmlFor="routine-name">Routine Name</Label>
<Input
className="mt-2"
id="routine-name"
maxLength={100}
placeholder="e.g., Upper Body"
/>
</div>
);
}

function ExerciseSection() {
return (
<div>
<Label asChild>
<h2>Exercises (0)</h2>
</Label>
<div className="mt-3">
<AddActionButton disabled>
<Plus aria-hidden="true" />
Add Exercise
</AddActionButton>
</div>
</div>
);
}
26 changes: 26 additions & 0 deletions apps/web/src/features/routines/routine-schedule-field.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<Label isOptional>Schedule</Label>

<div className="mt-2 flex min-h-14 items-center">
<Button
aria-label="Add schedule"
className="size-14 rounded-lg border-input bg-surface-elevated disabled:opacity-100"
disabled
size="icon"
type="button"
variant="outline"
>
<Plus aria-hidden="true" className="size-5" />
</Button>
</div>
</div>
);
}
34 changes: 25 additions & 9 deletions apps/web/src/features/routines/routines-landing.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -29,40 +35,46 @@ 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 (
<QueryClientProvider client={queryClient}>
<ApplicationIdentityContext.Provider value={identityValue}>
{children}
<RouterProvider router={testRouter} />
</ApplicationIdentityContext.Provider>
</QueryClientProvider>
);
};
}

describe('routines landing', () => {
it('shows a localized loading state', () => {
it('shows a localized loading state', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise(() => {})),
);

render(<RoutinesLanding />, { 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: [] })),
);

render(<RoutinesLanding />, { 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 () => {
Expand Down Expand Up @@ -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(<RoutinesLanding />, { 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();
});
});
Loading