diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..59828b0 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,120 @@ +# Architecture — Mini React Todo App + +This document describes the complete architecture of the Mini React Todo App, +a single-page application built with React, TypeScript, and Vite. + +## Overview + +A lightweight, client-side todo application that allows users to add, toggle, +and delete todo items. All state lives in the browser — there is no backend +or persistence layer. + +## Type Definitions + +The core data model is defined in `src/types.ts`: + +```typescript +export interface Todo { + id: string; // Unique identifier (crypto.randomUUID()) + text: string; // The todo item text + completed: boolean; // Whether the item has been completed +} +``` + +- **id** (`string`): Generated via `crypto.randomUUID()` to avoid collisions. +- **text** (`string`): User-supplied description of the task. +- **completed** (`boolean`): Toggled between `true` and `false`. + +## Component Tree + +``` +App +├── TodoInput +└── TodoList + └── TodoItem (one per todo) +``` + +- **App** → **TodoInput**: passes `addTodo` callback. +- **App** → **TodoList**: passes `todos`, `toggleTodo`, and `deleteTodo`. +- **TodoList** → **TodoItem**: passes individual `todo`, `toggleTodo`, and `deleteTodo`. + +## Component Responsibilities + +### App (`src/App.tsx`) + +- Owns application state via `useState`. +- Defines three state-mutation functions: + - `addTodo(text: string): void` — creates a new `Todo` with `crypto.randomUUID()` and prepends it. + - `toggleTodo(id: string): void` — flips the `completed` flag of the matching todo. + - `deleteTodo(id: string): void` — removes the matching todo from the list. +- Renders `` and `` with appropriate props. + +### TodoInput (`src/components/TodoInput.tsx`) + +- Maintains local `useState` for the input field value. +- On form submission: + - Trims whitespace; if the result is empty, does **not** call `addTodo`. + - Otherwise calls `addTodo(trimmedText)` and clears the input. +- Props: `{ addTodo: (text: string) => void }`. + +### TodoList (`src/components/TodoList.tsx`) + +- Receives the full `todos` array, `toggleTodo`, and `deleteTodo` as props. +- Maps over `todos` and renders a `` for each entry. +- Handles the empty-list case gracefully (renders a helpful message when `todos.length === 0`). +- Props: `{ todos: Todo[]; toggleTodo: (id: string) => void; deleteTodo: (id: string) => void }`. + +### TodoItem (`src/components/TodoItem.tsx`) + +- Renders a single todo with: + - A checkbox bound to `todo.completed` that calls `toggleTodo(todo.id)` on change. + - The todo text, visually struck through when completed. + - A delete button that calls `deleteTodo(todo.id)`. +- Props: `{ todo: Todo; toggleTodo: (id: string) => void; deleteTodo: (id: string) => void }`. + +## Data Flow + +The application follows a strict **unidirectional data flow**: + +1. **State** lives exclusively in `App` via `useState`. +2. **Props down**: `App` passes `todos` and callback functions down to children. +3. **Callbacks up**: Child components invoke callbacks (`addTodo`, `toggleTodo`, + `deleteTodo`) which update state in `App`, triggering a re-render. + +No child component mutates state directly. + +## State Management + +Only **React `useState`** is used for state management. No external state +libraries (Redux, Zustand, Jotai, MobX, etc.) are used. The entire +application state is a single `Todo[]` array held in the `App` component. + +## File Structure + +``` +/ +├── index.html # Vite HTML entry point +├── package.json # Dependencies and scripts +├── tsconfig.json # TypeScript configuration +├── vite.config.ts # Vite build configuration +├── RUNNING.md # Setup / run instructions +├── ARCHITECTURE.md # This file +└── src/ + ├── main.tsx # React DOM entry point + ├── App.tsx # Root component with state + ├── App.css # Application styles + ├── types.ts # Todo interface definition + ├── vite-env.d.ts # Vite client type references + └── components/ + ├── TodoInput.tsx # Input form component + ├── TodoItem.tsx # Single todo row component + └── TodoList.tsx # List container component +``` + +## Edge Cases + +- **Empty input**: `TodoInput` trims whitespace and prevents adding empty todos. +- **Empty list**: `TodoList` renders a fallback message when there are no todos. +- **ID collisions**: `crypto.randomUUID()` provides sufficient uniqueness. +- **No persistence**: State resets on page reload — localStorage is not included + in this base implementation. diff --git a/RUNNING.md b/RUNNING.md index 77896cf..3c05eef 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -1,33 +1,51 @@ -# Running the Todo API +# Running the Mini React Todo App ## Prerequisites -- Python 3.10 or later +- Node.js >= 18 +- npm >= 9 +- Python >= 3.9 (for running the test suite) -## Install dependencies +## Quick Start ```bash -pip install fastapi uvicorn pydantic +# Install dependencies +npm install + +# Start the Vite dev server +npm run dev ``` -For running the test suite you will also need: +The application will be available at **http://localhost:5173**. + +## Running Tests ```bash -pip install httpx pytest +# Install Python test dependencies +pip install pytest + +# Run the test suite +pytest tests/ ``` -## Start the server +## Docker (optional) ```bash -uvicorn main:app --reload --host 0.0.0.0 --port 8000 +docker compose up --build ``` -The API will be available at . +Open **http://localhost:5173** in your browser. -Interactive docs are served at . +## Notes -## Run the tests +- No authentication is required. +- No demo credentials needed. +- All state is in-memory (browser only); refreshing the page clears todos. -```bash -pytest tests/ -``` +## TEAM_BRIEF +stack: TypeScript/React+Vite +test_runner: pytest tests/ +lint_tool: none +coverage_tool: none +coverage_threshold: 0 +coverage_applies: false diff --git a/index.html b/index.html new file mode 100644 index 0000000..f661913 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Mini React Todo App + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..d8007b8 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "mini-react-todo-app", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "@vitejs/plugin-react": "^4.2.0" + } +} diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..916bb96 --- /dev/null +++ b/src/App.css @@ -0,0 +1,104 @@ +/* Application styles for the Mini React Todo App */ + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: #f5f5f5; + color: #333; +} + +.app { + max-width: 600px; + margin: 2rem auto; + padding: 1.5rem; + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.app h1 { + text-align: center; + margin-bottom: 1.5rem; + color: #1a1a2e; +} + +.todo-input-form { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.todo-input-form input { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 1rem; +} + +.todo-input-form button { + padding: 0.5rem 1rem; + background-color: #1a1a2e; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.todo-input-form button:hover { + background-color: #16213e; +} + +.todo-list-empty { + text-align: center; + color: #999; + padding: 1rem 0; +} + +.todo-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0; + border-bottom: 1px solid #eee; +} + +.todo-item:last-child { + border-bottom: none; +} + +.todo-item input[type='checkbox'] { + width: 1.2rem; + height: 1.2rem; + cursor: pointer; +} + +.todo-item .todo-text { + flex: 1; + font-size: 1rem; +} + +.todo-item .todo-text.completed { + text-decoration: line-through; + color: #999; +} + +.todo-item .delete-btn { + background: none; + border: none; + color: #e74c3c; + cursor: pointer; + font-size: 1.1rem; + padding: 0.25rem 0.5rem; +} + +.todo-item .delete-btn:hover { + color: #c0392b; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..0fdbaa1 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import { Todo } from './types'; +import TodoInput from './components/TodoInput'; +import TodoList from './components/TodoList'; +import './App.css'; + +/** + * Root application component. + * + * Owns the todo list state and provides addTodo, toggleTodo, and + * deleteTodo callbacks to child components. + */ +const App: React.FC = () => { + const [todos, setTodos] = useState([]); + + const addTodo = (text: string): void => { + const newTodo: Todo = { + id: crypto.randomUUID(), + text, + completed: false, + }; + setTodos((prev) => [newTodo, ...prev]); + }; + + const toggleTodo = (id: string): void => { + setTodos((prev) => + prev.map((todo) => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }; + + const deleteTodo = (id: string): void => { + setTodos((prev) => prev.filter((todo) => todo.id !== id)); + }; + + return ( +
+

Todo App

+ + +
+ ); +}; + +export default App; diff --git a/src/components/TodoInput.tsx b/src/components/TodoInput.tsx new file mode 100644 index 0000000..2853bf8 --- /dev/null +++ b/src/components/TodoInput.tsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; + +/** + * Props for the TodoInput component. + */ +interface TodoInputProps { + addTodo: (text: string) => void; +} + +/** + * Input form for creating new todo items. + * + * Trims whitespace from the input and prevents adding empty todos. + */ +const TodoInput: React.FC = ({ addTodo }) => { + const [text, setText] = useState(''); + + const handleSubmit = (e: React.FormEvent): void => { + e.preventDefault(); + const trimmed = text.trim(); + if (trimmed.length === 0) { + return; + } + addTodo(trimmed); + setText(''); + }; + + return ( +
+ setText(e.target.value)} + placeholder="Add a new todo..." + aria-label="Todo text" + /> + +
+ ); +}; + +export default TodoInput; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000..1381871 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Todo } from '../types'; + +/** + * Props for the TodoItem component. + */ +interface TodoItemProps { + todo: Todo; + toggleTodo: (id: string) => void; + deleteTodo: (id: string) => void; +} + +/** + * Renders a single todo item with a checkbox, text, and delete button. + */ +const TodoItem: React.FC = ({ todo, toggleTodo, deleteTodo }) => { + return ( +
+ toggleTodo(todo.id)} + aria-label={`Toggle ${todo.text}`} + /> + + {todo.text} + + +
+ ); +}; + +export default TodoItem; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000..fe0b4f8 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Todo } from '../types'; +import TodoItem from './TodoItem'; + +/** + * Props for the TodoList component. + */ +interface TodoListProps { + todos: Todo[]; + toggleTodo: (id: string) => void; + deleteTodo: (id: string) => void; +} + +/** + * Renders the list of todo items, or a message if the list is empty. + */ +const TodoList: React.FC = ({ todos, toggleTodo, deleteTodo }) => { + if (todos.length === 0) { + return

No todos yet. Add one above!

; + } + + return ( +
+ {todos.map((todo) => ( + + ))} +
+ ); +}; + +export default TodoList; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..98dc952 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './App.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2d03306 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,17 @@ +/** + * Core type definitions for the Mini React Todo App. + */ + +/** + * Represents a single Todo item. + */ +export interface Todo { + /** Unique identifier generated via crypto.randomUUID(). */ + id: string; + + /** The text description of the todo item. */ + text: string; + + /** Whether the todo item has been completed. */ + completed: boolean; +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000..32f3637 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,11 @@ +/** + * Represents a single todo item in the application. + */ +export interface Todo { + /** Unique identifier generated via crypto.randomUUID() */ + id: string; + /** The text content of the todo */ + text: string; + /** Whether the todo has been completed */ + completed: boolean; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tests/test_file_structure.py b/tests/test_file_structure.py new file mode 100644 index 0000000..68d8549 --- /dev/null +++ b/tests/test_file_structure.py @@ -0,0 +1,111 @@ +"""Test suite for validating the expected project file structure. + +Verifies that all required files and directories exist, that the +src/components/ directory contains exactly the expected component files, +that package.json references the correct dependencies, and that +src/types.ts contains the expected type definitions. +""" + +import os +from pathlib import Path +from typing import List + +import pytest + +# Root of the repository is one level above the tests/ directory. +ROOT: Path = Path(__file__).resolve().parent.parent + + +# ------------------------------------------------------------------ +# Individual file-existence tests +# ------------------------------------------------------------------ + +EXPECTED_FILES: List[str] = [ + "package.json", + "vite.config.ts", + "tsconfig.json", + "index.html", + "src/main.tsx", + "src/App.tsx", + "src/App.css", + "src/types.ts", + "src/vite-env.d.ts", + "src/components/TodoInput.tsx", + "src/components/TodoItem.tsx", + "src/components/TodoList.tsx", + "RUNNING.md", + "ARCHITECTURE.md", +] + + +@pytest.mark.parametrize("relative_path", EXPECTED_FILES) +def test_file_exists(relative_path: str) -> None: + """Verify that each expected project file exists on disk.""" + full_path: Path = ROOT / relative_path + assert full_path.exists(), f"Expected file not found: {relative_path}" + assert full_path.is_file(), f"Path exists but is not a file: {relative_path}" + + +@pytest.mark.parametrize("relative_path", EXPECTED_FILES) +def test_file_exists_os_path(relative_path: str) -> None: + """Verify file existence using os.path.exists as a secondary check.""" + full_path: str = os.path.join(str(ROOT), relative_path) + assert os.path.exists(full_path), f"os.path.exists failed for: {relative_path}" + + +# ------------------------------------------------------------------ +# Components directory structure +# ------------------------------------------------------------------ + + +def test_components_directory_contains_exactly_three_files() -> None: + """Verify src/components/ contains exactly the 3 expected component files.""" + components_dir: Path = ROOT / "src" / "components" + assert components_dir.exists(), "src/components/ directory does not exist" + assert components_dir.is_dir(), "src/components is not a directory" + + expected_component_files = { + "TodoInput.tsx", + "TodoItem.tsx", + "TodoList.tsx", + } + + actual_files = {f.name for f in components_dir.iterdir() if f.is_file()} + + assert actual_files == expected_component_files, ( + f"Expected exactly {expected_component_files} in src/components/, " + f"but found {actual_files}" + ) + + +# ------------------------------------------------------------------ +# package.json content validation +# ------------------------------------------------------------------ + + +def test_package_json_contains_required_dependencies() -> None: + """Verify package.json contains 'react', 'typescript', and 'vite' as substrings.""" + package_json_path: Path = ROOT / "package.json" + assert package_json_path.exists(), "package.json does not exist" + + content: str = package_json_path.read_text(encoding="utf-8") + + assert "react" in content, "package.json does not contain 'react'" + assert "typescript" in content, "package.json does not contain 'typescript'" + assert "vite" in content, "package.json does not contain 'vite'" + + +# ------------------------------------------------------------------ +# src/types.ts content validation +# ------------------------------------------------------------------ + + +def test_types_ts_contains_todo_and_completed() -> None: + """Verify src/types.ts contains 'Todo' and 'completed'.""" + types_path: Path = ROOT / "src" / "types.ts" + assert types_path.exists(), "src/types.ts does not exist" + + content: str = types_path.read_text(encoding="utf-8") + + assert "Todo" in content, "src/types.ts does not contain 'Todo'" + assert "completed" in content, "src/types.ts does not contain 'completed'" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a4c834a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..6d7a027 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + host: true, + }, +});