React Development · Lesson 12 of 15

Project: Task Manager

What You'll Build

A full-featured Task Manager (like a simplified Linear or Trello) with:

  • User authentication (login/register)
  • Projects with multiple task lists
  • Drag-and-drop task reordering
  • Task filtering, search, and sorting
  • Optimistic UI updates (instant feedback, sync in background)
  • Keyboard shortcuts
  • Responsive design with Tailwind CSS

Tech stack:

  • React 19 + TypeScript
  • Vite (fast dev server)
  • TanStack Query (server state)
  • Zustand (UI state)
  • React Hook Form + Zod (forms)
  • React Router v6 (routing)
  • Tailwind CSS (styling)
  • @dnd-kit (drag and drop)

Live preview of what you'll build:

┌─────────────────────────────────────────────────────────┐
│ 📋 TaskFlow          [Search...]      [Alice ▼] [+ New] │
├──────────┬──────────────────────────────────────────────┤
│ Projects │  🏃 In Progress          ✅ Done     📦 Backlog│
│          │  ┌─────────────────┐    ┌──────────┐         │
│ ▶ Design │  │ Design login UI │    │ Set up   │         │
│ ▼ Dev    │  │ @alice  🔴 High │    │ Vite     │         │
│   ▶ API  │  └─────────────────┘    └──────────┘         │
│   ▶ UI   │  ┌─────────────────┐                         │
│ ▶ Docs   │  │ Write API docs  │                         │
│          │  │ @bob  🟡 Medium │                         │
│          │  └─────────────────┘                         │
└──────────┴──────────────────────────────────────────────┘

Phase 1: Project Scaffolding

1.1 Create the Project

Bash
pnpm create vite@latest taskflow -- --template react-ts
cd taskflow

1.2 Install Dependencies

Bash
# Core
pnpm add react-router-dom @tanstack/react-query @tanstack/react-query-devtools
pnpm add zustand react-hook-form zod @hookform/resolvers

# UI
pnpm add tailwind-merge clsx lucide-react @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

# Dev
pnpm add -D tailwindcss postcss autoprefixer @types/node
npx tailwindcss init -p

1.3 Tailwind Setup

JavaScript
// tailwind.config.js
export default {
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#eff6ff",
          500: "#3b82f6",
          600: "#2563eb",
          700: "#1d4ed8",
        },
      },
    },
  },
};
CSS
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  body {
    @apply bg-gray-50 text-gray-900 antialiased;
  }
}

1.4 Folder Structure

src/
├── components/
│   ├── ui/           # Button, Input, Badge, Modal, Avatar
│   └── layout/       # AppShell, Sidebar, Header
├── features/
│   ├── auth/         # Login, Register, useAuth
│   ├── projects/     # ProjectList, ProjectForm
│   └── tasks/        # TaskBoard, TaskCard, TaskForm, DragDrop
├── hooks/            # useKeyboardShortcut, useDebounce
├── lib/              # cn(), api.ts, validators
├── store/            # Zustand stores
├── types/            # Global TypeScript types
└── router/           # Route definitions

1.5 Utility: cn() Helper

TSX
// src/lib/cn.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Phase 2: TypeScript Types

TSX
// src/types/index.ts

export type Priority = "low" | "medium" | "high" | "urgent";
export type TaskStatus = "backlog" | "in_progress" | "in_review" | "done";

export interface User {
  id: string;
  name: string;
  email: string;
  avatar: string | null;
}

export interface Project {
  id: string;
  name: string;
  description: string;
  color: string;
  ownerId: string;
  members: User[];
  createdAt: string;
  updatedAt: string;
}

export interface Task {
  id: string;
  title: string;
  description: string | null;
  status: TaskStatus;
  priority: Priority;
  projectId: string;
  assigneeId: string | null;
  assignee: User | null;
  dueDate: string | null;
  order: number;
  labels: string[];
  createdAt: string;
  updatedAt: string;
}

export interface CreateTaskDto {
  title: string;
  description?: string;
  status: TaskStatus;
  priority: Priority;
  projectId: string;
  assigneeId?: string;
  dueDate?: string;
  labels?: string[];
}

export interface UpdateTaskDto extends Partial<CreateTaskDto> {
  order?: number;
}

export interface ApiError {
  message: string;
  code: string;
  statusCode: number;
}

Phase 3: API Layer

TSX
// src/lib/api.ts
const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000";

class ApiClient {
  private token: string | null = null;

  setToken(token: string | null) {
    this.token = token;
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const headers: HeadersInit = {
      "Content-Type": "application/json",
      ...(this.token && { Authorization: `Bearer ${this.token}` }),
      ...options.headers,
    };

    const response = await fetch(`${BASE_URL}${endpoint}`, {
      ...options,
      headers,
    });

    if (!response.ok) {
      const error = await response.json().catch(() => ({
        message: "An unexpected error occurred",
        code: "UNKNOWN",
        statusCode: response.status,
      }));
      throw error;
    }

    // 204 No Content
    if (response.status === 204) return undefined as T;
    return response.json();
  }

  get<T>(endpoint: string) {
    return this.request<T>(endpoint);
  }

  post<T>(endpoint: string, body: unknown) {
    return this.request<T>(endpoint, {
      method: "POST",
      body: JSON.stringify(body),
    });
  }

  put<T>(endpoint: string, body: unknown) {
    return this.request<T>(endpoint, {
      method: "PUT",
      body: JSON.stringify(body),
    });
  }

  patch<T>(endpoint: string, body: unknown) {
    return this.request<T>(endpoint, {
      method: "PATCH",
      body: JSON.stringify(body),
    });
  }

  delete<T>(endpoint: string) {
    return this.request<T>(endpoint, { method: "DELETE" });
  }
}

export const api = new ApiClient();

Task API Functions

TSX
// src/features/tasks/api/taskApi.ts
import { api } from "@/lib/api";
import type { Task, CreateTaskDto, UpdateTaskDto } from "@/types";

export const taskApi = {
  getByProject: (projectId: string) =>
    api.get<Task[]>(`/projects/${projectId}/tasks`),

  getById: (id: string) =>
    api.get<Task>(`/tasks/${id}`),

  create: (data: CreateTaskDto) =>
    api.post<Task>("/tasks", data),

  update: (id: string, data: UpdateTaskDto) =>
    api.patch<Task>(`/tasks/${id}`, data),

  reorder: (projectId: string, taskIds: string[]) =>
    api.post<void>(`/projects/${projectId}/tasks/reorder`, { taskIds }),

  delete: (id: string) =>
    api.delete<void>(`/tasks/${id}`),
};

Phase 4: Authentication

4.1 Auth Store (Zustand)

TSX
// src/store/authStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { api } from "@/lib/api";
import type { User } from "@/types";

interface AuthState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  register: (name: string, email: string, password: string) => Promise<void>;
  logout: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      isAuthenticated: false,

      login: async (email, password) => {
        const { token, user } = await api.post<{ token: string; user: User }>(
          "/auth/login",
          { email, password }
        );
        api.setToken(token);
        set({ user, token, isAuthenticated: true });
      },

      register: async (name, email, password) => {
        const { token, user } = await api.post<{ token: string; user: User }>(
          "/auth/register",
          { name, email, password }
        );
        api.setToken(token);
        set({ user, token, isAuthenticated: true });
      },

      logout: () => {
        api.setToken(null);
        set({ user: null, token: null, isAuthenticated: false });
      },
    }),
    {
      name: "auth-storage",
      onRehydrateStorage: () => (state) => {
        // Restore token to API client when app loads
        if (state?.token) api.setToken(state.token);
      },
    }
  )
);

4.2 Login Form with Validation

TSX
// src/features/auth/components/LoginForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";

const loginSchema = z.object({
  email: z.string().email("Enter a valid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
});

type LoginFormData = z.infer<typeof loginSchema>;

export function LoginForm() {
  const navigate = useNavigate();
  const login = useAuthStore((s) => s.login);

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = async (data: LoginFormData) => {
    try {
      await login(data.email, data.password);
      navigate("/dashboard");
    } catch (err: any) {
      setError("root", {
        message: err.message ?? "Login failed. Check your credentials.",
      });
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="bg-white p-8 rounded-xl shadow-sm border border-gray-200 w-full max-w-md">
        <div className="mb-8">
          <h1 className="text-2xl font-bold text-gray-900">Welcome back</h1>
          <p className="text-gray-500 mt-1">Sign in to your TaskFlow account</p>
        </div>

        <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
          <Input
            label="Email"
            type="email"
            placeholder="alice@example.com"
            error={errors.email?.message}
            {...register("email")}
          />

          <Input
            label="Password"
            type="password"
            placeholder="••••••••"
            error={errors.password?.message}
            {...register("password")}
          />

          {errors.root && (
            <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
              {errors.root.message}
            </div>
          )}

          <Button
            type="submit"
            className="w-full"
            loading={isSubmitting}
          >
            Sign In
          </Button>
        </form>

        <p className="mt-4 text-center text-sm text-gray-500">
          Don't have an account?{" "}
          <a href="/register" className="text-brand-600 hover:underline font-medium">
            Sign up free
          </a>
        </p>
      </div>
    </div>
  );
}

Phase 5: Reusable UI Components

Button Component

TSX
// src/components/ui/Button.tsx
import { forwardRef } from "react";
import { cn } from "@/lib/cn";
import { Loader2 } from "lucide-react";

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "primary" | "secondary" | "ghost" | "danger";
  size?: "sm" | "md" | "lg";
  loading?: boolean;
  icon?: React.ReactNode;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      variant = "primary",
      size = "md",
      loading = false,
      icon,
      children,
      disabled,
      className,
      ...props
    },
    ref
  ) => {
    return (
      <button
        ref={ref}
        disabled={disabled || loading}
        className={cn(
          "inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed",
          {
            "bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800":
              variant === "primary",
            "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50":
              variant === "secondary",
            "text-gray-700 hover:bg-gray-100": variant === "ghost",
            "bg-red-600 text-white hover:bg-red-700": variant === "danger",
          },
          {
            "h-8 px-3 text-sm": size === "sm",
            "h-10 px-4 text-sm": size === "md",
            "h-12 px-6 text-base": size === "lg",
          },
          className
        )}
        {...props}
      >
        {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : icon}
        {children}
      </button>
    );
  }
);
Button.displayName = "Button";

Input Component

TSX
// src/components/ui/Input.tsx
import { forwardRef } from "react";
import { cn } from "@/lib/cn";

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  error?: string;
  hint?: string;
}

export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, hint, className, id, ...props }, ref) => {
    const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");

    return (
      <div className="space-y-1.5">
        {label && (
          <label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
            {label}
          </label>
        )}
        <input
          ref={ref}
          id={inputId}
          className={cn(
            "w-full px-3 py-2 text-sm border rounded-lg bg-white transition-colors",
            "placeholder:text-gray-400",
            "focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent",
            error
              ? "border-red-300 focus:ring-red-500"
              : "border-gray-300 hover:border-gray-400",
            className
          )}
          {...props}
        />
        {error && <p className="text-sm text-red-600">{error}</p>}
        {hint && !error && <p className="text-sm text-gray-500">{hint}</p>}
      </div>
    );
  }
);
Input.displayName = "Input";

Badge Component

TSX
// src/components/ui/Badge.tsx
import { cn } from "@/lib/cn";
import type { Priority, TaskStatus } from "@/types";

interface PriorityBadgeProps {
  priority: Priority;
}

const priorityConfig: Record<Priority, { label: string; className: string }> = {
  low: { label: "Low", className: "bg-gray-100 text-gray-700" },
  medium: { label: "Medium", className: "bg-yellow-100 text-yellow-800" },
  high: { label: "High", className: "bg-orange-100 text-orange-800" },
  urgent: { label: "Urgent", className: "bg-red-100 text-red-700" },
};

export function PriorityBadge({ priority }: PriorityBadgeProps) {
  const config = priorityConfig[priority];
  return (
    <span
      className={cn(
        "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
        config.className
      )}
    >
      {config.label}
    </span>
  );
}

Phase 6: Task Board with React Query

6.1 Task Hooks

TSX
// src/features/tasks/hooks/useTasks.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { taskApi } from "../api/taskApi";
import type { CreateTaskDto, UpdateTaskDto } from "@/types";
import { toast } from "@/lib/toast";

export const taskKeys = {
  all: ["tasks"] as const,
  byProject: (projectId: string) => [...taskKeys.all, "project", projectId] as const,
  detail: (id: string) => [...taskKeys.all, "detail", id] as const,
};

export function useProjectTasks(projectId: string) {
  return useQuery({
    queryKey: taskKeys.byProject(projectId),
    queryFn: () => taskApi.getByProject(projectId),
    staleTime: 30_000,  // 30 seconds before refetch
  });
}

export function useCreateTask() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateTaskDto) => taskApi.create(data),

    onMutate: async (newTask) => {
      // Cancel outgoing refetches to avoid overwriting optimistic update
      await queryClient.cancelQueries({
        queryKey: taskKeys.byProject(newTask.projectId),
      });

      // Snapshot the previous value
      const previousTasks = queryClient.getQueryData(
        taskKeys.byProject(newTask.projectId)
      );

      // Optimistically add the task
      queryClient.setQueryData(
        taskKeys.byProject(newTask.projectId),
        (old: any) => [
          ...(old ?? []),
          {
            id: `temp-${Date.now()}`,
            ...newTask,
            assignee: null,
            order: 999,
            labels: newTask.labels ?? [],
            createdAt: new Date().toISOString(),
            updatedAt: new Date().toISOString(),
          },
        ]
      );

      return { previousTasks };
    },

    onError: (err: any, newTask, context) => {
      // Rollback on error
      queryClient.setQueryData(
        taskKeys.byProject(newTask.projectId),
        context?.previousTasks
      );
      toast.error(err.message ?? "Failed to create task");
    },

    onSuccess: (task) => {
      queryClient.invalidateQueries({
        queryKey: taskKeys.byProject(task.projectId),
      });
      toast.success("Task created");
    },
  });
}

export function useUpdateTask() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateTaskDto }) =>
      taskApi.update(id, data),

    onSuccess: (updatedTask) => {
      queryClient.invalidateQueries({
        queryKey: taskKeys.byProject(updatedTask.projectId),
      });
    },

    onError: (err: any) => {
      toast.error(err.message ?? "Failed to update task");
    },
  });
}

export function useDeleteTask(projectId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => taskApi.delete(id),

    onMutate: async (deletedId) => {
      await queryClient.cancelQueries({ queryKey: taskKeys.byProject(projectId) });
      const prev = queryClient.getQueryData(taskKeys.byProject(projectId));

      queryClient.setQueryData(
        taskKeys.byProject(projectId),
        (old: any) => old?.filter((t: any) => t.id !== deletedId) ?? []
      );

      return { prev };
    },

    onError: (err: any, _, context) => {
      queryClient.setQueryData(taskKeys.byProject(projectId), context?.prev);
      toast.error(err.message ?? "Failed to delete task");
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: taskKeys.byProject(projectId) });
    },
  });
}

6.2 Task Board Component

TSX
// src/features/tasks/components/TaskBoard.tsx
import { useState, useMemo } from "react";
import { Plus } from "lucide-react";
import { useProjectTasks } from "../hooks/useTasks";
import { TaskColumn } from "./TaskColumn";
import { TaskForm } from "./TaskForm";
import { Button } from "@/components/ui/Button";
import type { Task, TaskStatus } from "@/types";

const COLUMNS: { id: TaskStatus; label: string; color: string }[] = [
  { id: "backlog", label: "Backlog", color: "bg-gray-400" },
  { id: "in_progress", label: "In Progress", color: "bg-blue-500" },
  { id: "in_review", label: "In Review", color: "bg-yellow-500" },
  { id: "done", label: "Done", color: "bg-green-500" },
];

interface TaskBoardProps {
  projectId: string;
}

export function TaskBoard({ projectId }: TaskBoardProps) {
  const { data: tasks, isLoading, isError } = useProjectTasks(projectId);
  const [search, setSearch] = useState("");
  const [priorityFilter, setPriorityFilter] = useState<string>("all");
  const [isCreating, setIsCreating] = useState(false);

  const filteredTasks = useMemo(() => {
    if (!tasks) return [];
    return tasks.filter((task) => {
      const matchesSearch =
        !search || task.title.toLowerCase().includes(search.toLowerCase());
      const matchesPriority =
        priorityFilter === "all" || task.priority === priorityFilter;
      return matchesSearch && matchesPriority;
    });
  }, [tasks, search, priorityFilter]);

  const tasksByStatus = useMemo(() => {
    const grouped: Record<TaskStatus, Task[]> = {
      backlog: [],
      in_progress: [],
      in_review: [],
      done: [],
    };
    filteredTasks.forEach((task) => {
      grouped[task.status].push(task);
    });
    // Sort by order within each column
    Object.values(grouped).forEach((col) =>
      col.sort((a, b) => a.order - b.order)
    );
    return grouped;
  }, [filteredTasks]);

  if (isLoading) return <TaskBoardSkeleton />;
  if (isError) return <BoardError onRetry={() => {}} />;

  return (
    <div className="flex flex-col h-full">
      {/* Toolbar */}
      <div className="flex items-center gap-3 p-4 border-b border-gray-200 bg-white">
        <input
          type="search"
          placeholder="Search tasks..."
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          className="flex-1 max-w-xs px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
        />
        <select
          value={priorityFilter}
          onChange={(e) => setPriorityFilter(e.target.value)}
          className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-brand-500"
        >
          <option value="all">All Priorities</option>
          <option value="urgent">Urgent</option>
          <option value="high">High</option>
          <option value="medium">Medium</option>
          <option value="low">Low</option>
        </select>

        <div className="ml-auto">
          <Button icon={<Plus className="h-4 w-4" />} onClick={() => setIsCreating(true)}>
            New Task
          </Button>
        </div>
      </div>

      {/* Board */}
      <div className="flex gap-4 p-4 flex-1 overflow-x-auto">
        {COLUMNS.map((column) => (
          <TaskColumn
            key={column.id}
            column={column}
            tasks={tasksByStatus[column.id]}
            projectId={projectId}
          />
        ))}
      </div>

      {/* Create Task Modal */}
      {isCreating && (
        <TaskForm
          projectId={projectId}
          onClose={() => setIsCreating(false)}
        />
      )}
    </div>
  );
}

function TaskBoardSkeleton() {
  return (
    <div className="flex gap-4 p-4">
      {[1, 2, 3, 4].map((i) => (
        <div key={i} className="w-72 shrink-0">
          <div className="h-8 bg-gray-200 rounded animate-pulse mb-4" />
          {[1, 2, 3].map((j) => (
            <div key={j} className="h-24 bg-gray-100 rounded-lg animate-pulse mb-2" />
          ))}
        </div>
      ))}
    </div>
  );
}

6.3 Task Card

TSX
// src/features/tasks/components/TaskCard.tsx
import { useState } from "react";
import { MoreHorizontal, Trash2, Edit } from "lucide-react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { PriorityBadge } from "@/components/ui/Badge";
import { useDeleteTask } from "../hooks/useTasks";
import type { Task } from "@/types";
import { cn } from "@/lib/cn";
import { format } from "date-fns";

interface TaskCardProps {
  task: Task;
  onClick: () => void;
}

export function TaskCard({ task, onClick }: TaskCardProps) {
  const [menuOpen, setMenuOpen] = useState(false);
  const { mutate: deleteTask } = useDeleteTask(task.projectId);

  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: task.id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  const isOverdue =
    task.dueDate && new Date(task.dueDate) < new Date() && task.status !== "done";

  return (
    <div
      ref={setNodeRef}
      style={style}
      {...attributes}
      {...listeners}
      className={cn(
        "bg-white rounded-lg border border-gray-200 p-3 cursor-grab active:cursor-grabbing",
        "hover:border-gray-300 hover:shadow-sm transition-all group",
        isDragging && "opacity-50 shadow-lg rotate-2"
      )}
      onClick={onClick}
    >
      <div className="flex items-start justify-between gap-2">
        <p className="text-sm font-medium text-gray-900 line-clamp-2">
          {task.title}
        </p>
        <button
          onClick={(e) => {
            e.stopPropagation();
            setMenuOpen(!menuOpen);
          }}
          className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-100 rounded transition-opacity"
        >
          <MoreHorizontal className="h-4 w-4 text-gray-500" />
        </button>
      </div>

      {task.description && (
        <p className="text-xs text-gray-500 mt-1 line-clamp-2">
          {task.description}
        </p>
      )}

      <div className="flex items-center gap-2 mt-3">
        <PriorityBadge priority={task.priority} />

        {task.dueDate && (
          <span
            className={cn(
              "text-xs",
              isOverdue ? "text-red-600 font-medium" : "text-gray-500"
            )}
          >
            {isOverdue ? "" : ""}
            {format(new Date(task.dueDate), "MMM d")}
          </span>
        )}

        {task.assignee && (
          <div className="ml-auto">
            {task.assignee.avatar ? (
              <img
                src={task.assignee.avatar}
                alt={task.assignee.name}
                className="h-6 w-6 rounded-full"
                title={task.assignee.name}
              />
            ) : (
              <div className="h-6 w-6 rounded-full bg-brand-100 text-brand-700 text-xs flex items-center justify-center font-medium">
                {task.assignee.name[0].toUpperCase()}
              </div>
            )}
          </div>
        )}
      </div>

      {/* Context menu */}
      {menuOpen && (
        <div
          className="absolute right-0 top-8 z-10 bg-white border border-gray-200 rounded-lg shadow-lg py-1 w-36"
          onClick={(e) => e.stopPropagation()}
        >
          <button
            className="w-full px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
            onClick={() => { setMenuOpen(false); onClick(); }}
          >
            <Edit className="h-3.5 w-3.5" /> Edit
          </button>
          <button
            className="w-full px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
            onClick={() => {
              if (confirm("Delete this task?")) deleteTask(task.id);
              setMenuOpen(false);
            }}
          >
            <Trash2 className="h-3.5 w-3.5" /> Delete
          </button>
        </div>
      )}
    </div>
  );
}

Phase 7: Drag and Drop

TSX
// src/features/tasks/components/TaskColumn.tsx
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { TaskCard } from "./TaskCard";
import { cn } from "@/lib/cn";
import type { Task, TaskStatus } from "@/types";

interface TaskColumnProps {
  column: { id: TaskStatus; label: string; color: string };
  tasks: Task[];
  projectId: string;
}

export function TaskColumn({ column, tasks, projectId }: TaskColumnProps) {
  const { setNodeRef, isOver } = useDroppable({ id: column.id });

  return (
    <div className="w-72 shrink-0 flex flex-col">
      {/* Column header */}
      <div className="flex items-center gap-2 mb-3">
        <div className={cn("h-2.5 w-2.5 rounded-full", column.color)} />
        <h3 className="text-sm font-semibold text-gray-700">{column.label}</h3>
        <span className="ml-auto text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
          {tasks.length}
        </span>
      </div>

      {/* Drop zone */}
      <div
        ref={setNodeRef}
        className={cn(
          "flex-1 flex flex-col gap-2 min-h-16 rounded-lg p-1 transition-colors",
          isOver && "bg-brand-50 ring-2 ring-brand-200"
        )}
      >
        <SortableContext
          items={tasks.map((t) => t.id)}
          strategy={verticalListSortingStrategy}
        >
          {tasks.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              onClick={() => {/* open task detail */}}
            />
          ))}
        </SortableContext>

        {tasks.length === 0 && !isOver && (
          <div className="flex-1 flex items-center justify-center text-xs text-gray-400 border-2 border-dashed border-gray-200 rounded-lg py-8">
            Drop tasks here
          </div>
        )}
      </div>
    </div>
  );
}

// Wrapping everything with DndContext in the board
// src/features/tasks/components/TaskBoard.tsx (DnD addition)
import {
  DndContext,
  DragEndEvent,
  DragOverEvent,
  DragStartEvent,
  PointerSensor,
  useSensor,
  useSensors,
  closestCorners,
} from "@dnd-kit/core";

// Inside TaskBoard component, add drag handlers:
const sensors = useSensors(
  useSensor(PointerSensor, {
    activationConstraint: { distance: 8 },  // Don't start drag on click
  })
);

const [activeTask, setActiveTask] = useState<Task | null>(null);
const updateTask = useUpdateTask();

const handleDragStart = ({ active }: DragStartEvent) => {
  const task = tasks?.find((t) => t.id === active.id);
  setActiveTask(task ?? null);
};

const handleDragEnd = ({ active, over }: DragEndEvent) => {
  setActiveTask(null);
  if (!over || active.id === over.id) return;

  const newStatus = over.id as TaskStatus;
  const isColumn = COLUMNS.some((c) => c.id === newStatus);

  if (isColumn) {
    updateTask.mutate({
      id: active.id as string,
      data: { status: newStatus },
    });
  }
};

Phase 8: Router Setup

TSX
// src/router/index.tsx
import { createBrowserRouter, Navigate } from "react-router-dom";
import { AppShell } from "@/components/layout/AppShell";
import { LoginPage } from "@/features/auth/pages/LoginPage";
import { RegisterPage } from "@/features/auth/pages/RegisterPage";
import { DashboardPage } from "@/features/projects/pages/DashboardPage";
import { ProjectPage } from "@/features/projects/pages/ProjectPage";
import { useAuthStore } from "@/store/authStore";

function RequireAuth({ children }: { children: React.ReactNode }) {
  const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
  return isAuthenticated ? children : <Navigate to="/login" replace />;
}

export const router = createBrowserRouter([
  { path: "/login", element: <LoginPage /> },
  { path: "/register", element: <RegisterPage /> },
  {
    path: "/",
    element: <RequireAuth><AppShell /></RequireAuth>,
    children: [
      { index: true, element: <Navigate to="/dashboard" replace /> },
      { path: "dashboard", element: <DashboardPage /> },
      { path: "projects/:projectId", element: <ProjectPage /> },
    ],
  },
]);

Phase 9: App Entry Point

TSX
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { router } from "./router";
import "./index.css";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,          // 1 minute
      retry: (failureCount, error: any) => {
        if (error?.statusCode === 401 || error?.statusCode === 403) return false;
        return failureCount < 3;
      },
    },
    mutations: {
      retry: false,
    },
  },
});

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </StrictMode>
);

Phase 10: What to Build Next

You have the foundation. Here's what to add to make it production-ready:

Feature           | Key Concepts Practiced
──────────────────┼──────────────────────────────────────────
Task Detail Modal │ Portal, React.memo, complex form state
Comments          │ Real-time updates, WebSocket, optimistic UI
File Attachments  │ Multipart upload, progress tracking
Notifications     │ Context, polling or WebSocket, toast system
Team Management   │ RBAC, admin-only routes, conditional UI
Dark Mode         │ CSS variables, context, localStorage
Export to PDF/CSV │ Browser APIs, Blob, URL.createObjectURL
Keyboard Shortcuts│ useEffect, event delegation, useKeyboard

Running the Project

Bash
# Development
pnpm dev

# Type checking
pnpm type-check

# Build for production
pnpm build

# Preview production build locally
pnpm preview

Open http://localhost:3000 — you have a working task management app.