22 KiB
Design: Task Management UI
Architecture Approach
This feature transforms studio-ui from a static placeholder dashboard into a functional task management application. It adds client-side routing, API integration, state management, and CRUD pages that consume the REST APIs defined by the data-models feature.
| Layer | What Changes |
|---|---|
Routing (src/App.tsx) |
Replace static content with React Router; add route definitions |
Pages (src/pages/) |
New page components: ProjectList, ProjectDetail |
Components (src/components/) |
New feature components: ProjectCard, TaskBoard, TaskColumn, TaskCard, dialogs |
Hooks (src/hooks/) |
Custom hooks for API data fetching and mutation |
API (src/api/) |
API client instance and typed request functions |
Types (src/types/) |
TypeScript interfaces matching backend response shapes |
Navigation (src/App.tsx) |
Update sidebar items to include Projects route |
The existing shared packages (ui, layout, auth, api-client) are consumed as-is. No shared package changes are required.
Data Model (Frontend Types)
TypeScript interfaces mirroring the backend API response shapes:
// src/types/project.ts
interface Project {
id: string;
name: string;
description: string;
status: "active" | "archived";
created_at: string;
updated_at: string;
}
interface CreateProjectRequest {
name: string;
description?: string;
}
interface UpdateProjectRequest {
name: string;
description?: string;
status?: "active" | "archived";
}
// src/types/task.ts
type TaskStatus = "todo" | "in_progress" | "done";
type TaskPriority = "low" | "medium" | "high";
interface Task {
id: string;
project_id: string;
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
position: number;
created_at: string;
updated_at: string;
}
interface CreateTaskRequest {
title: string;
description?: string;
priority?: TaskPriority;
}
interface UpdateTaskRequest {
title?: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
position?: number;
}
// src/types/label.ts
interface Label {
id: string;
project_id: string;
name: string;
color: string;
created_at: string;
updated_at: string;
}
interface CreateLabelRequest {
name: string;
color: string;
}
// src/types/assignment.ts
interface Assignment {
id: string;
task_id: string;
assignee: string;
created_at: string;
}
interface CreateAssignmentRequest {
assignee: string;
}
All responses from the API are wrapped in the {data, meta} envelope:
interface APIResponse<T> {
data: T;
meta?: {
request_id: string;
timestamp: string;
};
}
API Integration Layer
Client Setup
// src/api/client.ts
import { createClient } from '@foundary-test-1770773605/api-client';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8001';
export const api = createClient({
baseUrl: `${API_BASE}/api/studio-api`,
});
API Functions
Typed wrappers around the API client, one file per resource:
// src/api/projects.ts
export async function listProjects(): Promise<Project[]> {
const res = await api.get<APIResponse<Project[]>>('/projects');
return res.data;
}
export async function getProject(id: string): Promise<Project> {
const res = await api.get<APIResponse<Project>>(`/projects/${id}`);
return res.data;
}
export async function createProject(req: CreateProjectRequest): Promise<Project> {
const res = await api.post<APIResponse<Project>>('/projects', req);
return res.data;
}
export async function updateProject(id: string, req: UpdateProjectRequest): Promise<Project> {
const res = await api.put<APIResponse<Project>>(`/projects/${id}`, req);
return res.data;
}
export async function deleteProject(id: string): Promise<void> {
await api.delete(`/projects/${id}`);
}
Similar patterns for src/api/tasks.ts, src/api/labels.ts, src/api/assignments.ts. Task and label create/list endpoints use the nested project path (/projects/{projectId}/tasks), while get/update/delete use flat paths (/tasks/{id}).
State Management
Approach: React hooks + local component state
No global state library (Redux, Zustand) is needed. Each page manages its own data via custom hooks that encapsulate fetch/mutate/loading/error state. This matches the simplicity of the current codebase.
Custom Hooks
// src/hooks/useProjects.ts
function useProjects() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => { ... }, []);
useEffect(() => { refresh(); }, [refresh]);
return { projects, loading, error, refresh };
}
// src/hooks/useProject.ts
function useProject(id: string) {
// Fetches single project + its tasks + labels
const [project, setProject] = useState<Project | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [labels, setLabels] = useState<Label[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
// Parallel fetch: project, tasks, labels
const [proj, taskList, labelList] = await Promise.all([
getProject(id),
listTasks(id),
listLabels(id),
]);
setProject(proj);
setTasks(taskList);
setLabels(labelList);
}, [id]);
return { project, tasks, labels, loading, error, refresh };
}
After a mutation (create/update/delete), the hook's refresh() is called to re-fetch from the server. This avoids stale state and keeps the implementation simple.
Component Diagram
src/
├── App.tsx # Router + DashboardShell layout
├── api/
│ ├── client.ts # API client instance
│ ├── projects.ts # Project API functions
│ ├── tasks.ts # Task API functions
│ ├── labels.ts # Label API functions
│ └── assignments.ts # Assignment API functions
├── types/
│ ├── project.ts # Project types
│ ├── task.ts # Task types
│ ├── label.ts # Label types
│ └── assignment.ts # Assignment types
├── hooks/
│ ├── useProjects.ts # Project list data
│ └── useProject.ts # Single project + tasks + labels
├── pages/
│ ├── ProjectListPage.tsx # /projects route
│ └── ProjectDetailPage.tsx # /projects/:id route
├── components/
│ ├── ProjectCard.tsx # Card for project list
│ ├── CreateProjectDialog.tsx # Dialog for creating/editing projects
│ ├── DeleteConfirmDialog.tsx # Reusable delete confirmation
│ ├── TaskBoard.tsx # Kanban-style board with columns
│ ├── TaskColumn.tsx # Single status column
│ ├── TaskCard.tsx # Individual task card
│ ├── CreateTaskDialog.tsx # Dialog for creating/editing tasks
│ ├── LabelList.tsx # Label management section
│ ├── CreateLabelDialog.tsx # Dialog for creating labels
│ └── AssignmentList.tsx # Assignment display in task detail
├── index.css
├── main.tsx
└── lib/
└── logger.ts
Component Interaction Flow
App.tsx
├── DashboardShell (from @layout)
│ ├── Sidebar → nav items including /projects
│ ├── Header
│ └── <Routes>
│ ├── /projects → ProjectListPage
│ │ ├── useProjects() hook
│ │ ├── ProjectCard[] (grid)
│ │ └── CreateProjectDialog
│ │
│ └── /projects/:id → ProjectDetailPage
│ ├── useProject(id) hook
│ ├── Project header (name, status, actions)
│ ├── TaskBoard
│ │ ├── TaskColumn (todo)
│ │ │ └── TaskCard[]
│ │ ├── TaskColumn (in_progress)
│ │ │ └── TaskCard[]
│ │ └── TaskColumn (done)
│ │ └── TaskCard[]
│ ├── CreateTaskDialog
│ ├── LabelList
│ │ └── CreateLabelDialog
│ └── DeleteConfirmDialog
Page Designs
Project List Page (/projects)
┌─────────────────────────────────────────────────┐
│ Header: "Projects" [+ New Project] │
├─────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Project A │ │ Project B │ │ Project C │ │
│ │ │ │ │ │ │ │
│ │ Desc... │ │ Desc... │ │ Desc... │ │
│ │ │ │ │ │ │ │
│ │ ●Active │ │ ●Active │ │ ○Archived │ │
│ │ 5 tasks │ │ 12 tasks │ │ 3 tasks │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ No projects yet? Create your first one! │ │
│ │ [+ Create Project] │ │
│ └──────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
- Grid layout:
grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 - Empty state card when no projects exist
- Project cards link to
/projects/:id - Badge shows status (success for active, secondary for archived)
Project Detail Page (/projects/:id)
┌─────────────────────────────────────────────────┐
│ Projects > Project A [Edit] [Del] │
├─────────────────────────────────────────────────┤
│ │
│ Labels: [Bug] [Feature] [Docs] [+ Add Label] │
│ │
│ ┌─────────────┬──────────────┬───────────────┐ │
│ │ Todo (3) │ In Progress │ Done (5) │ │
│ │ │ (2) │ │ │
│ │ ┌─────────┐│ ┌──────────┐ │ ┌───────────┐ │ │
│ │ │ Task 1 ││ │ Task 4 │ │ │ Task 6 │ │ │
│ │ │ ▲ High ││ │ ● Medium │ │ │ ▼ Low │ │ │
│ │ │ @alice ││ │ @bob │ │ │ @alice │ │ │
│ │ └─────────┘│ └──────────┘ │ └───────────┘ │ │
│ │ ┌─────────┐│ │ ┌───────────┐ │ │
│ │ │ Task 2 ││ │ │ Task 7 │ │ │
│ │ │ ● Med ││ │ │ ● Medium │ │ │
│ │ └─────────┘│ │ └───────────┘ │ │
│ │ │ │ │ │
│ │ [+ Add] │ │ │ │
│ └─────────────┴──────────────┴───────────────┘ │
│ │
└─────────────────────────────────────────────────┘
- Kanban board with three fixed columns: Todo, In Progress, Done
- Each column has a header with count badge
- Task cards show title, priority badge, assignee(s)
- Clicking a task card opens an edit sheet/dialog
- "Add Task" button at the bottom of the Todo column (or a floating button)
- Labels displayed as a horizontal row of badges above the board
Key Component Specifications
ProjectCard
// Uses: Card, CardHeader, CardTitle, CardDescription, CardContent, Badge
// Props: project: Project, taskCount?: number, onClick: () => void
// Behavior: Click navigates to /projects/:id
TaskBoard
// Props: tasks: Task[], onCreateTask, onUpdateTask, onDeleteTask
// Groups tasks by status into three TaskColumn components
// Horizontal scroll on narrow viewports: flex with overflow-x-auto
TaskCard
// Uses: Card, Badge
// Props: task: Task, assignments: Assignment[], onClick: () => void
// Shows: title, priority badge (color-coded), assignee avatars/names
// Click opens task edit dialog
Priority Badge Colors
| Priority | Badge Variant | Color |
|---|---|---|
| High | error |
Red |
| Medium | warning |
Yellow/Amber |
| Low | info |
Blue |
Status Column Colors
| Status | Label |
|---|---|
todo |
"Todo" |
in_progress |
"In Progress" |
done |
"Done" |
CreateProjectDialog
// Uses: Dialog, Input, Textarea, Button, Label
// Fields: name (required, max 200), description (optional, max 1000)
// Mode: create (POST) or edit (PUT) based on whether project prop is passed
// On submit: calls API, then refresh() from hook
CreateTaskDialog
// Uses: Dialog, Input, Textarea, Select, Button, Label
// Fields: title (required, max 300), description (optional, max 5000),
// priority (select: low/medium/high), status (select: todo/in_progress/done)
// Mode: create (POST, status defaults to todo) or edit (PUT)
DeleteConfirmDialog
// Uses: Dialog, Button
// Props: title, message, onConfirm, onCancel
// "Are you sure?" with destructive-variant confirm button
// Reusable for projects, tasks, labels
Routing Setup
// src/App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<DashboardShell sidebar={<Sidebar items={navItems} />} header={...}>
<Routes>
<Route path="/" element={<Navigate to="/projects" replace />} />
<Route path="/projects" element={<ProjectListPage />} />
<Route path="/projects/:id" element={<ProjectDetailPage />} />
</Routes>
</DashboardShell>
</BrowserRouter>
);
}
The sidebar navItems will be updated:
const navItems: NavItem[] = [
{ label: 'Projects', href: '/projects', icon: FolderKanban, active: true },
{ label: 'Settings', href: '/settings', icon: Settings },
];
Active state should be derived from current route via useLocation().
Error Handling Strategy
API Error Handling
All API functions handle the {data, meta} envelope and throw on non-2xx responses:
// Errors propagated from api-client
try {
await createProject(req);
} catch (err) {
// err.status: HTTP status code
// err.message: error message from server
// err.body: full error response
}
Per-Component Error States
| Scenario | UI Behavior |
|---|---|
| Page load failure | Alert banner: "Failed to load projects. Try again." + retry button |
| Create/Update validation (400/422) | Field-level error messages in dialog |
| Duplicate name conflict (409) | Inline error: "A project with this name already exists" |
| Not found (404) | Redirect to project list with toast/alert |
| Network error | Alert banner: "Network error. Check your connection." |
| Delete failure | Toast/alert: "Failed to delete. Try again." |
Error Display Components
Errors are shown using the existing Alert component:
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
Security Considerations
Input Validation (Client-Side)
Client-side validation mirrors backend constraints to provide immediate feedback:
- Project name: required, 1-200 chars
- Project description: max 1000 chars
- Task title: required, 1-300 chars
- Task description: max 5000 chars
- Label name: required, 1-50 chars
- Label color: hex format validation (#RRGGBB)
- Assignee: required, 1-200 chars
Client-side validation is defense in depth — the backend always re-validates.
XSS Prevention
- React's JSX auto-escapes rendered content — no
dangerouslySetInnerHTMLused - User-provided strings (names, descriptions) rendered as text nodes
- Label colors rendered only as CSS
background-colorvalues, validated as hex
Authentication
- Auth is out of scope per spec — no login UI or token management in this feature
- The API client supports bearer token injection when auth is later enabled
- Write operations (create/update/delete) work without auth in development (matching backend's
AUTH_ENABLED=falsedefault)
CORS
- Dev mode: Vite proxy or CORS headers configured for
localhost:3001→localhost:8001 - Production: Same-origin or configured CORS in the backend
Performance Considerations
Data Fetching
- Project list: Single API call (
GET /projects). No pagination needed for initial scale. - Project detail: Three parallel API calls (
GET /projects/:id,GET /projects/:id/tasks,GET /projects/:id/labels). UsesPromise.allfor concurrent loading. - Assignments: Fetched lazily when a task card is expanded/clicked, not on page load. This avoids N+1 API calls.
Rendering
- Task board: Three columns rendered as simple lists. No virtualization needed at current scale (< 100 tasks per project).
- React key optimization: All lists keyed by entity
id(UUID). - Dialog components: Rendered conditionally (not mounted until triggered).
Bundle Size
- No additional large dependencies. Uses existing React, React Router, and shared packages.
- lucide-react icons are tree-shaken per import.
- No chart libraries or heavy visualization for this feature.
Caching
- No client-side cache layer in v1. Each page navigation re-fetches.
- Future optimization: add SWR or React Query for caching, deduplication, and background refetching.
Migration / Rollout Plan
Prerequisites
- Backend: The
data-modelsfeature must be implemented and deployed first. The API endpoints (/projects,/tasks, etc.) must be available. - Database: PostgreSQL with migrations applied (handled by
data-modelsfeature).
Rollout Steps
Step 1: Add routing and page structure
- Install
react-router-dom(if not already present) - Replace static
App.tsxcontent with router setup - Create empty page components
Step 2: Add API integration layer
- Create
src/api/client.tswith base configuration - Create typed API function files per resource
- Create TypeScript type definitions
Step 3: Build project list page
ProjectListPagewithuseProjectshookProjectCardcomponentCreateProjectDialog(create + edit modes)DeleteConfirmDialog(reusable)
Step 4: Build project detail page
ProjectDetailPagewithuseProjecthookTaskBoard+TaskColumn+TaskCardCreateTaskDialog(create + edit modes)LabelList+CreateLabelDialogAssignmentListwithin task edit
Step 5: Polish and integration
- Loading states, error states, empty states
- Navigation active states from route
- Environment variable for API URL
Backward Compatibility
- The static dashboard content is replaced, not preserved alongside
- No existing user data to migrate — this is a new feature
- The sidebar navigation changes (removes placeholder items, adds Projects)
- No API contract changes — this is a pure frontend addition
Environment Variables
| Variable | Default | Purpose |
|---|---|---|
VITE_API_URL |
http://localhost:8001 |
Backend API base URL |
Testing Strategy
- Manual testing: All CRUD operations through the UI against the running backend
- Component tests: Optional unit tests for complex components (TaskBoard grouping logic)
- E2E tests: Out of scope for v1; can be added later with Playwright/Cypress
Design Decisions
-
React hooks over global state: The app is simple enough that local state per page is sufficient. No Redux/Zustand complexity. Custom hooks encapsulate fetch logic and make it reusable.
-
Kanban board over table view: Task management benefits from visual status columns. The three-column board (Todo, In Progress, Done) provides immediate status overview. Table view can be added as an alternative later.
-
Dialogs over separate pages for CRUD: Create/edit operations use modal dialogs rather than navigating to separate form pages. This keeps the user in context and matches the design system's Dialog component.
-
Lazy assignment loading: Assignments are fetched per-task when clicked, not upfront. This avoids N+1 queries on page load and keeps initial render fast.
-
No drag-and-drop in v1: The backend has a
positionfield, but implementing drag-and-drop adds significant complexity (library dependency, position recalculation, optimistic updates). Deferred to a follow-up feature. -
Shared DeleteConfirmDialog: One reusable confirmation dialog rather than per-entity delete dialogs. Reduces code duplication.
-
Environment-based API URL: Uses
VITE_API_URLfor the backend base URL. In production, this can point to the deployed API. In development, it defaults tolocalhost:8001. -
No client-side routing library beyond React Router: The sidebar's
NavItempattern already supportshrefstrings. Active state detection usesuseLocation()from React Router.