diff --git a/admin-web/components/exercise-list.test.tsx b/admin-web/components/exercise-list.test.tsx
new file mode 100644
index 0000000..4a3f04c
--- /dev/null
+++ b/admin-web/components/exercise-list.test.tsx
@@ -0,0 +1,239 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor, fireEvent } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { ExerciseList } from './exercise-list'
+
+describe('ExerciseList', () => {
+ const mockOnChange = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render with empty list showing add button', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('Add Exercise')).toBeInTheDocument()
+ })
+
+ it('should add new exercise on button click', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const addButton = screen.getByText('Add Exercise')
+ await user.click(addButton)
+
+ expect(mockOnChange).toHaveBeenCalledWith([
+ { name: 'Push-ups', duration: 20 },
+ { name: '', duration: 20 },
+ ])
+ })
+
+ it('should remove exercise on delete click', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const deleteButton = screen.getByTestId('delete-exercise-0')
+ await user.click(deleteButton)
+
+ expect(mockOnChange).toHaveBeenCalledWith([
+ { name: 'Squats', duration: 20 },
+ ])
+ })
+
+ it('should update exercise name', () => {
+ render(
+
+ )
+
+ // Get the input and simulate change directly using fireEvent
+ const nameInput = screen.getByTestId('exercise-name-0')
+ fireEvent.change(nameInput, { target: { value: 'Burpees' } })
+
+ // Check that onChange was called with correct value
+ expect(mockOnChange).toHaveBeenCalledWith([{ name: 'Burpees', duration: 20 }])
+ })
+
+ it('should update exercise duration', () => {
+ render(
+
+ )
+
+ // Get the input and simulate change directly using fireEvent
+ const durationInput = screen.getByTestId('exercise-duration-0')
+ fireEvent.change(durationInput, { target: { value: '30' } })
+
+ // Check that onChange was called with correct value
+ expect(mockOnChange).toHaveBeenCalledWith([{ name: 'Push-ups', duration: 30 }])
+ })
+
+ it('should disable delete button when only one exercise', () => {
+ render(
+
+ )
+
+ const deleteButton = screen.getByTestId('delete-exercise-0')
+ expect(deleteButton).toBeDisabled()
+ })
+
+ it('should use workTimeDefault for new exercises', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const addButton = screen.getByText('Add Exercise')
+ await user.click(addButton)
+
+ expect(mockOnChange).toHaveBeenCalledWith([
+ { name: '', duration: 45 },
+ ])
+ })
+
+ it('should move exercise up', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const upButton = screen.getByTestId('move-up-1')
+ expect(upButton).not.toBeDisabled()
+
+ await user.click(upButton)
+ expect(mockOnChange).toHaveBeenCalledWith([
+ { name: 'Second', duration: 20 },
+ { name: 'First', duration: 20 },
+ ])
+ })
+
+ it('should move exercise down', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const downButton = screen.getByTestId('move-down-0')
+ expect(downButton).not.toBeDisabled()
+
+ await user.click(downButton)
+ expect(mockOnChange).toHaveBeenCalledWith([
+ { name: 'Second', duration: 20 },
+ { name: 'First', duration: 20 },
+ ])
+ })
+
+ it('should disable move up on first item', () => {
+ render(
+
+ )
+
+ const upButton = screen.getByTestId('move-up-0')
+ expect(upButton).toBeDisabled()
+ })
+
+ it('should disable move down on last item', () => {
+ render(
+
+ )
+
+ const downButton = screen.getByTestId('move-down-1')
+ expect(downButton).toBeDisabled()
+ })
+
+ it('should respect disabled prop', () => {
+ render(
+
+ )
+
+ expect(screen.getByTestId('exercise-name-0')).toBeDisabled()
+ expect(screen.getByTestId('exercise-duration-0')).toBeDisabled()
+ expect(screen.getByText('Add Exercise')).toBeDisabled()
+ })
+
+ it('should render correct number of exercises', () => {
+ render(
+
+ )
+
+ // Check that we have 3 exercise name inputs
+ expect(screen.getByTestId('exercise-name-0')).toBeInTheDocument()
+ expect(screen.getByTestId('exercise-name-1')).toBeInTheDocument()
+ expect(screen.getByTestId('exercise-name-2')).toBeInTheDocument()
+
+ // Verify move buttons have correct aria-labels
+ expect(screen.getByTestId('move-up-0')).toHaveAttribute('aria-label', 'Move exercise 1 up')
+ expect(screen.getByTestId('move-up-1')).toHaveAttribute('aria-label', 'Move exercise 2 up')
+ expect(screen.getByTestId('move-up-2')).toHaveAttribute('aria-label', 'Move exercise 3 up')
+ })
+})
diff --git a/admin-web/components/exercise-list.tsx b/admin-web/components/exercise-list.tsx
new file mode 100644
index 0000000..010449a
--- /dev/null
+++ b/admin-web/components/exercise-list.tsx
@@ -0,0 +1,161 @@
+"use client"
+
+import * as React from "react"
+import { Plus, Trash2, GripVertical } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+
+interface Exercise {
+ name: string
+ duration: number
+}
+
+interface ExerciseListProps {
+ value: Exercise[]
+ onChange: (value: Exercise[]) => void
+ workTimeDefault?: number
+ className?: string
+ disabled?: boolean
+}
+
+function ExerciseList({
+ value,
+ onChange,
+ workTimeDefault = 20,
+ className,
+ disabled = false,
+}: ExerciseListProps) {
+ const handleAdd = () => {
+ onChange([...value, { name: "", duration: workTimeDefault }])
+ }
+
+ const handleRemove = (index: number) => {
+ onChange(value.filter((_, i) => i !== index))
+ }
+
+ const handleUpdate = (
+ index: number,
+ field: keyof Exercise,
+ newValue: string | number
+ ) => {
+ const updated = [...value]
+ updated[index] = { ...updated[index], [field]: newValue }
+ onChange(updated)
+ }
+
+ const handleMove = (index: number, direction: "up" | "down") => {
+ if (
+ (direction === "up" && index === 0) ||
+ (direction === "down" && index === value.length - 1)
+ ) {
+ return
+ }
+
+ const newIndex = direction === "up" ? index - 1 : index + 1
+ const updated = [...value]
+ const [moved] = updated.splice(index, 1)
+ updated.splice(newIndex, 0, moved)
+ onChange(updated)
+ }
+
+ return (
+
+
+ {value.map((exercise, index) => (
+
+
+
+
+
+
+
+
+
+ handleUpdate(index, "name", e.target.value)}
+ placeholder="e.g., Jumping Jacks"
+ disabled={disabled}
+ className="h-9"
+ data-testid={`exercise-name-${index}`}
+ />
+
+
+
+
+
+
+ handleUpdate(index, "duration", parseInt(e.target.value) || 0)
+ }
+ disabled={disabled}
+ className="h-9"
+ min={1}
+ data-testid={`exercise-duration-${index}`}
+ />
+ s
+
+
+
+
+
+
+ ))}
+
+
+
+
+ )
+}
+
+export { ExerciseList }
+export type { Exercise }
diff --git a/admin-web/components/tag-input.test.tsx b/admin-web/components/tag-input.test.tsx
new file mode 100644
index 0000000..925d8f9
--- /dev/null
+++ b/admin-web/components/tag-input.test.tsx
@@ -0,0 +1,248 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { TagInput } from './tag-input'
+
+describe('TagInput', () => {
+ const mockOnChange = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render empty input with placeholder', () => {
+ render(
+
+ )
+
+ expect(screen.getByPlaceholderText('Add equipment...')).toBeInTheDocument()
+ })
+
+ it('should add tag on Enter key', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const input = screen.getByRole('textbox')
+ await user.type(input, 'Dumbbells')
+ await user.keyboard('{Enter}')
+
+ expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
+ })
+
+ it('should add tag on blur', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const input = screen.getByRole('textbox')
+ await user.type(input, 'Yoga Mat')
+ await user.tab() // blur
+
+ expect(mockOnChange).toHaveBeenCalledWith(['Yoga Mat'])
+ })
+
+ it('should not add empty tag', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const input = screen.getByRole('textbox')
+ await user.type(input, ' ')
+ await user.keyboard('{Enter}')
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should not add duplicate tags', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const input = screen.getByRole('textbox')
+ await user.type(input, 'Dumbbells')
+ await user.keyboard('{Enter}')
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should remove tag on X click', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ // Find and click the remove button on first tag
+ const removeButtons = screen.getAllByRole('button', { name: '' })
+ await user.click(removeButtons[0])
+
+ expect(mockOnChange).toHaveBeenCalledWith(['Yoga Mat'])
+ })
+
+ it('should remove last tag on Backspace when input empty', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const input = screen.getByRole('textbox')
+ await user.click(input)
+ await user.keyboard('{Backspace}')
+
+ expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
+ })
+
+ it('should not remove tag on Backspace when input has text', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const input = screen.getByRole('textbox')
+ await user.type(input, 'Yoga')
+ await user.keyboard('{Backspace}')
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should display all tags', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('Dumbbells')).toBeInTheDocument()
+ expect(screen.getByText('Yoga Mat')).toBeInTheDocument()
+ expect(screen.getByText('Resistance Band')).toBeInTheDocument()
+ })
+
+ it('should trim whitespace from tags', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const input = screen.getByRole('textbox')
+ await user.type(input, ' Dumbbells ')
+ await user.keyboard('{Enter}')
+
+ expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
+ })
+
+ it('should respect disabled prop', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('textbox')).toBeDisabled()
+ // Remove button on tag should not be present when disabled
+ const removeButtons = screen.queryAllByRole('button', { name: '' })
+ expect(removeButtons).toHaveLength(0)
+ })
+
+ it('should clear input after adding tag', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+ )
+
+ const input = screen.getByRole('textbox') as HTMLInputElement
+ await user.type(input, 'Dumbbells')
+ await user.keyboard('{Enter}')
+
+ expect(input.value).toBe('')
+ })
+
+ it('should handle multiple tags addition', async () => {
+ const user = userEvent.setup()
+ const { rerender } = render(
+
+ )
+
+ const input = screen.getByRole('textbox')
+
+ // Add first tag
+ await user.type(input, 'Dumbbells')
+ await user.keyboard('{Enter}')
+ expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
+
+ // Rerender with updated value
+ rerender(
+
+ )
+
+ // Add second tag
+ await user.type(input, 'Yoga Mat')
+ await user.keyboard('{Enter}')
+ expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells', 'Yoga Mat'])
+
+ // Rerender with updated value
+ rerender(
+
+ )
+
+ // Add third tag
+ await user.type(input, 'Resistance Band')
+ await user.keyboard('{Enter}')
+ expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells', 'Yoga Mat', 'Resistance Band'])
+ })
+})
diff --git a/admin-web/components/tag-input.tsx b/admin-web/components/tag-input.tsx
new file mode 100644
index 0000000..c149303
--- /dev/null
+++ b/admin-web/components/tag-input.tsx
@@ -0,0 +1,100 @@
+"use client"
+
+import * as React from "react"
+import { X, Plus } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Input } from "@/components/ui/input"
+
+interface TagInputProps {
+ value: string[]
+ onChange: (value: string[]) => void
+ placeholder?: string
+ className?: string
+ disabled?: boolean
+ id?: string
+}
+
+function TagInput({
+ value,
+ onChange,
+ placeholder = "Add tag...",
+ className,
+ disabled = false,
+}: TagInputProps) {
+ const [inputValue, setInputValue] = React.useState("")
+ const inputRef = React.useRef(null)
+
+ const handleAddTag = () => {
+ const trimmed = inputValue.trim()
+ if (trimmed && !value.includes(trimmed)) {
+ onChange([...value, trimmed])
+ setInputValue("")
+ }
+ }
+
+ const handleRemoveTag = (tagToRemove: string) => {
+ onChange(value.filter((tag) => tag !== tagToRemove))
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ handleAddTag()
+ } else if (e.key === "Backspace" && !inputValue && value.length > 0) {
+ handleRemoveTag(value[value.length - 1])
+ }
+ }
+
+ return (
+
+ {value.map((tag) => (
+
+ {tag}
+ {!disabled && (
+
+ )}
+
+ ))}
+
+
setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={handleAddTag}
+ placeholder={value.length === 0 ? placeholder : ""}
+ disabled={disabled}
+ className="flex-1 border-0 bg-transparent px-0 py-1 text-white placeholder:text-neutral-500 focus-visible:ring-0 focus-visible:ring-offset-0"
+ />
+ {inputValue.trim() && (
+
+ )}
+
+
+ )
+}
+
+export { TagInput }