Questions

Designing a Sprint Board

Build a flexible drag-and-drop task management system like Trello or Jira

Designing a Sprint Board

Build a flexible digital Sprint Board that helps teams visualize and manage tasks efficiently with drag-and-drop functionality, real-time collaboration, and modern frontend architecture.


Problem Statement

Design a comprehensive task management system similar to Trello, Jira, or Asana with the following capabilities:

  • Drag-and-drop task movement between columns and within columns
  • Real-time collaboration with multiple users
  • Task creation and editing with rich metadata
  • Dynamic workflow columns that can be customized
  • Task filtering and search capabilities
  • Progress tracking and analytics
  • Mobile responsiveness for all devices
  • Offline support with synchronization

Clarifying Questions

Functional Requirements

  • Team Size: How many users will be using the board simultaneously?
  • Task Volume: How many tasks per column/board are expected?
  • Workflow Complexity: How many columns and workflow stages are needed?
  • Collaboration: Do multiple users need to edit the same task simultaneously?
  • Integrations: Should it integrate with other tools (GitHub, Slack, etc.)?
  • Reporting: What kind of analytics and reporting are required?

Technical Constraints

  • Performance: How many tasks should it handle efficiently?
  • Real-time Updates: What's the acceptable latency for collaborative updates?
  • Data Persistence: How should data be stored and backed up?
  • Mobile Support: Should it work on touch devices with gestures?
  • Offline Capability: Should it work without internet connection?

High-Level Architecture

1. Component Architecture

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Board View    │    │  State Manager  │    │  Real-time      │
│   (Main App)    │◄──►│  (Redux/Zustand)│◄──►│  Sync (WebSocket)│
└─────────────────┘    └─────────────────┘    └─────────────────┘
         │                       │                       │
         ▼                       ▼                       ▼
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Column List   │    │   Cache Layer   │    │   API Client    │
│   (Drag Context)│    │   (IndexedDB)   │    │   (REST/GraphQL)│
└─────────────────┘    └─────────────────┘    └─────────────────┘

2. Core Components

Board Component

  • Main container for the entire sprint board
  • Drag context provider for cross-column operations
  • Real-time updates handling
  • Responsive layout management

Column Component

  • Task container with drop zones
  • Column header with title and task count
  • Add task button and column settings
  • Scrollable task list with virtualization

Task Component

  • Draggable task card with rich content
  • Task details (title, description, assignee, due date)
  • Priority indicators and status badges
  • Quick actions (edit, delete, duplicate)

Drag-and-Drop System

  • Cross-column movement with visual feedback
  • Within-column reordering with smooth animations
  • Drop zone indicators and validation
  • Touch gesture support for mobile devices

Drag-and-Drop Implementation

React DnD Setup

import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { TouchBackend } from 'react-dnd-touch-backend';

Task Dragging Logic

interface Task {
  id: string;
  title: string;
  description?: string;
  assignee?: string;
  priority: 'low' | 'medium' | 'high';
  dueDate?: Date;
  columnId: string;
  order: number;
  createdAt: Date;
  updatedAt: Date;
}

interface Column {
  id: string;
  title: string;
  order: number;
  taskIds: string[];
  color?: string;
  isArchived: boolean;
}

const useTaskDrag = (task: Task, onMove: (taskId: string, sourceColumnId: string, targetColumnId: string, newOrder: number) => void) => {
  const [{ isDragging }, drag] = useDrag({
    type: 'TASK',
    item: { id: task.id, columnId: task.columnId, type: 'TASK' },
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
    end: (item, monitor) => {
      const dropResult = monitor.getDropResult();
      if (item && dropResult) {
        const { columnId: targetColumnId, order: newOrder } = dropResult;
        onMove(item.id, item.columnId, targetColumnId, newOrder);
      }
    },
  });

  return { drag, isDragging };
};

const TaskCard: React.FC<{ task: Task; onMove: Function }> = ({ task, onMove }) => {
  const { drag, isDragging } = useTaskDrag(task, onMove);

  return (
    <div
      ref={drag}
      className={`task-card ${isDragging ? 'dragging' : ''}`}
      style={{
        opacity: isDragging ? 0.5 : 1,
        cursor: 'grab',
      }}
    >
      <div className="task-header">
        <span className={`priority-badge priority-${task.priority}`}>
          {task.priority}
        </span>
        {task.dueDate && (
          <span className={`due-date ${isOverdue(task.dueDate) ? 'overdue' : ''}`}>
            {formatDate(task.dueDate)}
          </span>
        )}
      </div>
      
      <h4 className="task-title">{task.title}</h4>
      
      {task.description && (
        <p className="task-description">{task.description}</p>
      )}
      
      <div className="task-footer">
        {task.assignee && (
          <div className="assignee">
            <Avatar user={task.assignee} size="small" />
          </div>
        )}
        
        <div className="task-actions">
          <button onClick={() => onEdit(task.id)}>Edit</button>
          <button onClick={() => onDelete(task.id)}>Delete</button>
        </div>
      </div>
    </div>
  );
};

Column Drop Zone

const useColumnDrop = (columnId: string, onDrop: (taskId: string, columnId: string, order: number) => void) => {
  const [{ isOver }, drop] = useDrop({
    accept: 'TASK',
    drop: (item: { id: string; columnId: string }, monitor) => {
      if (item.columnId !== columnId) {
        const order = getNewOrder(columnId, monitor.getClientOffset());
        onDrop(item.id, columnId, order);
      }
    },
    collect: (monitor) => ({
      isOver: monitor.isOver(),
    }),
  });

  return { drop, isOver };
};

const Column: React.FC<{ column: Column; tasks: Task[]; onDrop: Function }> = ({ column, tasks, onDrop }) => {
  const { drop, isOver } = useColumnDrop(column.id, onDrop);

  return (
    <div
      ref={drop}
      className={`column ${isOver ? 'drop-over' : ''}`}
      style={{
        backgroundColor: isOver ? '#f0f8ff' : '#f5f5f5',
        border: isOver ? '2px dashed #007acc' : '1px solid #ddd',
      }}
    >
      <div className="column-header">
        <h3>{column.title}</h3>
        <span className="task-count">{tasks.length}</span>
        <button onClick={() => onAddTask(column.id)}>+</button>
      </div>
      
      <div className="task-list">
        {tasks.map((task, index) => (
          <TaskCard
            key={task.id}
            task={task}
            onMove={onMoveTask}
          />
        ))}
      </div>
    </div>
  );
};

State Management

Redux Store Structure

interface BoardState {
  columns: {
    [columnId: string]: Column;
  };
  tasks: {
    [taskId: string]: Task;
  };
  board: {
    id: string;
    title: string;
    description?: string;
    columnOrder: string[];
    settings: BoardSettings;
  };
  ui: {
    selectedTask: string | null;
    editingTask: string | null;
    draggedTask: string | null;
    loading: boolean;
    error: string | null;
  };
  collaboration: {
    onlineUsers: User[];
    typingUsers: { [columnId: string]: string[] };
    lastActivity: { [taskId: string]: Date };
  };
}

interface BoardSettings {
  allowTaskCreation: boolean;
  allowTaskEditing: boolean;
  allowColumnCreation: boolean;
  maxTasksPerColumn: number;
  autoArchive: boolean;
  notifications: NotificationSettings;
}

interface NotificationSettings {
  taskAssigned: boolean;
  taskMoved: boolean;
  taskCompleted: boolean;
  mentions: boolean;
}

Actions and Reducers

const boardActions = {
  // Task actions
  createTask: createAction<{ columnId: string; task: Partial<Task> }>('board/createTask'),
  updateTask: createAction<{ taskId: string; updates: Partial<Task> }>('board/updateTask'),
  deleteTask: createAction<string>('board/deleteTask'),
  moveTask: createAction<{ taskId: string; sourceColumnId: string; targetColumnId: string; newOrder: number }>('board/moveTask'),
  
  // Column actions
  createColumn: createAction<Partial<Column>>('board/createColumn'),
  updateColumn: createAction<{ columnId: string; updates: Partial<Column> }>('board/updateColumn'),
  deleteColumn: createAction<string>('board/deleteColumn'),
  reorderColumns: createAction<string[]>('board/reorderColumns'),
  
  // UI actions
  selectTask: createAction<string | null>('board/selectTask'),
  setEditingTask: createAction<string | null>('board/setEditingTask'),
  setDraggedTask: createAction<string | null>('board/setDraggedTask'),
  
  // Collaboration actions
  userJoined: createAction<User>('board/userJoined'),
  userLeft: createAction<string>('board/userLeft'),
  setTypingUser: createAction<{ columnId: string; userId: string; isTyping: boolean }>('board/setTypingUser'),
};

const boardReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(boardActions.createTask, (state, action) => {
      const { columnId, task } = action.payload;
      const newTask: Task = {
        id: generateId(),
        ...task,
        columnId,
        order: state.columns[columnId].taskIds.length,
        createdAt: new Date(),
        updatedAt: new Date(),
      } as Task;
      
      state.tasks[newTask.id] = newTask;
      state.columns[columnId].taskIds.push(newTask.id);
    })
    .addCase(boardActions.moveTask, (state, action) => {
      const { taskId, sourceColumnId, targetColumnId, newOrder } = action.payload;
      
      // Remove from source column
      const sourceColumn = state.columns[sourceColumnId];
      sourceColumn.taskIds = sourceColumn.taskIds.filter(id => id !== taskId);
      
      // Add to target column
      const targetColumn = state.columns[targetColumnId];
      targetColumn.taskIds.splice(newOrder, 0, taskId);
      
      // Update task
      state.tasks[taskId].columnId = targetColumnId;
      state.tasks[taskId].updatedAt = new Date();
      
      // Reorder tasks in both columns
      this.reorderTasksInColumn(state, sourceColumnId);
      this.reorderTasksInColumn(state, targetColumnId);
    })
    .addCase(boardActions.updateTask, (state, action) => {
      const { taskId, updates } = action.payload;
      state.tasks[taskId] = {
        ...state.tasks[taskId],
        ...updates,
        updatedAt: new Date(),
      };
    });
});

Real-Time Collaboration

WebSocket Integration

class BoardWebSocket {
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 5;

  constructor(private boardId: string, private userId: string) {}

  connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(`ws://localhost:3001/board/${this.boardId}?userId=${this.userId}`);
      
      this.ws.onopen = () => {
        console.log('Connected to board');
        this.reconnectAttempts = 0;
        resolve();
      };

      this.ws.onmessage = (event) => {
        const message = JSON.parse(event.data);
        this.handleMessage(message);
      };

      this.ws.onclose = () => {
        console.log('Disconnected from board');
        this.handleReconnect();
      };

      this.ws.onerror = reject;
    });
  }

  private handleMessage(message: any) {
    switch (message.type) {
      case 'TASK_CREATED':
        store.dispatch(boardActions.createTask(message.payload));
        break;
      case 'TASK_UPDATED':
        store.dispatch(boardActions.updateTask(message.payload));
        break;
      case 'TASK_MOVED':
        store.dispatch(boardActions.moveTask(message.payload));
        break;
      case 'USER_JOINED':
        store.dispatch(boardActions.userJoined(message.payload));
        break;
      case 'USER_LEFT':
        store.dispatch(boardActions.userLeft(message.payload.userId));
        break;
      case 'TYPING_UPDATE':
        store.dispatch(boardActions.setTypingUser(message.payload));
        break;
    }
  }

  sendMessage(type: string, payload: any) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type, payload }));
    }
  }

  private handleReconnect() {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++;
      setTimeout(() => {
        this.connect().catch(console.error);
      }, 1000 * this.reconnectAttempts);
    }
  }
}

UI Components

Main Board Component

const BoardView: React.FC = () => {
  const dispatch = useDispatch();
  const { columns, tasks, board, ui } = useSelector((state: RootState) => state.board);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    loadBoardData().finally(() => setIsLoading(false));
  }, []);

  const handleTaskMove = useCallback((taskId: string, sourceColumnId: string, targetColumnId: string, newOrder: number) => {
    dispatch(boardActions.moveTask({ taskId, sourceColumnId, targetColumnId, newOrder }));
    
    // Send to server
    websocket.sendMessage('TASK_MOVED', { taskId, sourceColumnId, targetColumnId, newOrder });
  }, [dispatch]);

  const handleTaskCreate = useCallback((columnId: string, taskData: Partial<Task>) => {
    dispatch(boardActions.createTask({ columnId, task: taskData }));
    
    // Send to server
    websocket.sendMessage('TASK_CREATED', { columnId, task: taskData });
  }, [dispatch]);

  if (isLoading) {
    return <div className="loading">Loading board...</div>;
  }

  return (
    <div className="board-container">
      <div className="board-header">
        <h1>{board.title}</h1>
        <div className="board-actions">
          <button onClick={() => setShowAddColumn(true)}>Add Column</button>
          <button onClick={() => setShowBoardSettings(true)}>Settings</button>
        </div>
      </div>
      
      <div className="board-content">
        <div className="columns-container">
          {board.columnOrder.map(columnId => {
            const column = columns[columnId];
            const columnTasks = column.taskIds.map(taskId => tasks[taskId]);
            
            return (
              <Column
                key={columnId}
                column={column}
                tasks={columnTasks}
                onDrop={handleTaskMove}
                onCreateTask={handleTaskCreate}
              />
            );
          })}
        </div>
      </div>
      
      {ui.selectedTask && (
        <TaskModal
          task={tasks[ui.selectedTask]}
          onClose={() => dispatch(boardActions.selectTask(null))}
          onUpdate={(updates) => {
            dispatch(boardActions.updateTask({ taskId: ui.selectedTask!, updates }));
            websocket.sendMessage('TASK_UPDATED', { taskId: ui.selectedTask!, updates });
          }}
        />
      )}
    </div>
  );
};

Task Creation Modal

const TaskModal: React.FC<{ task?: Task; onClose: () => void; onUpdate: (updates: Partial<Task>) => void }> = ({ task, onClose, onUpdate }) => {
  const [formData, setFormData] = useState({
    title: task?.title || '',
    description: task?.description || '',
    assignee: task?.assignee || '',
    priority: task?.priority || 'medium',
    dueDate: task?.dueDate ? formatDate(task.dueDate) : '',
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    const updates: Partial<Task> = {
      title: formData.title,
      description: formData.description,
      assignee: formData.assignee,
      priority: formData.priority as Task['priority'],
      dueDate: formData.dueDate ? new Date(formData.dueDate) : undefined,
    };
    
    onUpdate(updates);
    onClose();
  };

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={e => e.stopPropagation()}>
        <h2>{task ? 'Edit Task' : 'Create Task'}</h2>
        
        <form onSubmit={handleSubmit}>
          <div className="form-group">
            <label>Title</label>
            <input
              type="text"
              value={formData.title}
              onChange={e => setFormData(prev => ({ ...prev, title: e.target.value }))}
              required
            />
          </div>
          
          <div className="form-group">
            <label>Description</label>
            <textarea
              value={formData.description}
              onChange={e => setFormData(prev => ({ ...prev, description: e.target.value }))}
              rows={3}
            />
          </div>
          
          <div className="form-row">
            <div className="form-group">
              <label>Assignee</label>
              <select
                value={formData.assignee}
                onChange={e => setFormData(prev => ({ ...prev, assignee: e.target.value }))}
              >
                <option value="">Unassigned</option>
                <option value="user1">John Doe</option>
                <option value="user2">Jane Smith</option>
              </select>
            </div>
            
            <div className="form-group">
              <label>Priority</label>
              <select
                value={formData.priority}
                onChange={e => setFormData(prev => ({ ...prev, priority: e.target.value }))}
              >
                <option value="low">Low</option>
                <option value="medium">Medium</option>
                <option value="high">High</option>
              </select>
            </div>
          </div>
          
          <div className="form-group">
            <label>Due Date</label>
            <input
              type="date"
              value={formData.dueDate}
              onChange={e => setFormData(prev => ({ ...prev, dueDate: e.target.value }))}
            />
          </div>
          
          <div className="modal-actions">
            <button type="button" onClick={onClose}>Cancel</button>
            <button type="submit">{task ? 'Update' : 'Create'}</button>
          </div>
        </form>
        </div>
    </div>
  );
};

Performance Optimization

Virtual Scrolling for Large Task Lists

import { FixedSizeList as List } from 'react-window';

const VirtualizedTaskList: React.FC<{ tasks: Task[]; columnId: string }> = ({ tasks, columnId }) => {
  const TaskRow = ({ index, style }: { index: number; style: CSSProperties }) => {
    const task = tasks[index];
    
    return (
      <div style={style}>
        <TaskCard task={task} />
      </div>
    );
  };

  return (
    <List
      height={600}
      itemCount={tasks.length}
      itemSize={120}
      width="100%"
    >
      {TaskRow}
    </List>
  );
};

Optimistic Updates

const useOptimisticUpdate = () => {
  const dispatch = useDispatch();
  
  const optimisticMoveTask = useCallback((taskId: string, sourceColumnId: string, targetColumnId: string, newOrder: number) => {
    // Optimistic update
    dispatch(boardActions.moveTask({ taskId, sourceColumnId, targetColumnId, newOrder }));
    
    // Send to server
    return websocket.sendMessage('TASK_MOVED', { taskId, sourceColumnId, targetColumnId, newOrder })
      .catch(error => {
        // Revert on error
        dispatch(boardActions.moveTask({ taskId, targetColumnId, sourceColumnId, newOrder }));
        throw error;
      });
  }, [dispatch]);
  
  return { optimisticMoveTask };
};

Security and Validation

Input Validation

const validateTask = (task: Partial<Task>): ValidationResult => {
  const errors: string[] = [];
  
  if (!task.title?.trim()) {
    errors.push('Task title is required');
  }
  
  if (task.title && task.title.length > 200) {
    errors.push('Task title must be less than 200 characters');
  }
  
  if (task.description && task.description.length > 1000) {
    errors.push('Task description must be less than 1000 characters');
  }
  
  if (task.dueDate && new Date(task.dueDate) < new Date()) {
    errors.push('Due date cannot be in the past');
  }
  
  return {
    isValid: errors.length === 0,
    errors,
  };
};

Best Practices

Performance

  • Virtual Scrolling for large task lists
  • Optimistic Updates for immediate feedback
  • Debounced API calls to reduce server load
  • Image optimization for avatars and attachments

User Experience

  • Smooth animations for drag-and-drop operations
  • Visual feedback for all user actions
  • Keyboard shortcuts for power users
  • Mobile-responsive design with touch gestures

Collaboration

  • Real-time updates with WebSocket
  • Conflict resolution for simultaneous edits
  • User presence indicators
  • Activity logging and audit trails

Accessibility

  • Keyboard navigation support
  • Screen reader compatibility
  • ARIA labels and roles
  • Focus management for modals and forms

Interview Tips

What Interviewers Look For:

  1. Drag-and-drop implementation understanding
  2. Real-time collaboration handling
  3. State management for complex UI interactions
  4. Performance optimization for large datasets

Sample Questions:

  • "How would you handle 10,000 tasks in a column?"
  • "What if two users edit the same task simultaneously?"
  • "How would you implement undo/redo functionality?"
  • "How would you optimize for mobile devices?"

Key Points to Mention:

  • Use React DnD or similar library for drag-and-drop
  • Implement optimistic updates for better UX
  • Plan for real-time collaboration with WebSocket
  • Consider performance with virtualization
  • Think about accessibility from the beginning

Next: Pinterest Design - Learn how to build a responsive image grid with infinite scrolling.