Terminal.skills
Use Cases/Build a Drag-and-Drop Kanban Board

Build a Drag-and-Drop Kanban Board

Build a real-time drag-and-drop Kanban board with columns, card ordering, optimistic updates, WebSocket sync across users, and persistent state — a Trello-style project management UI.

#nextjs#react#full-stack#ssr#server-components
Works with:claude-codeopenai-codexgemini-clicursor
$

The Problem

Mika leads product at a 20-person startup using spreadsheets to track tasks. Columns are "To Do", "In Progress", "Review", "Done" — but the spreadsheet doesn't show flow. Team members overwrite each other's changes. Nobody knows the order of priority within a column. They tried Jira but it's too heavy; Trello is too simple (no custom fields, limited automation). They need a custom Kanban board: drag cards between columns, reorder within columns, real-time sync when teammates make changes, and custom fields for their workflow.

Step 1: Build the Kanban Backend

typescript
// src/kanban/board.ts — Kanban board with ordering, drag-drop, and real-time sync
import { pool } from "../db";
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

interface Board {
  id: string;
  name: string;
  columns: Column[];
}

interface Column {
  id: string;
  name: string;
  color: string;
  order: number;
  wipLimit?: number;           // work-in-progress limit
  cards: Card[];
}

interface Card {
  id: string;
  title: string;
  description: string;
  columnId: string;
  order: number;               // fractional ordering for easy inserts
  assigneeId: string | null;
  priority: "low" | "medium" | "high" | "urgent";
  labels: string[];
  dueDate: string | null;
  createdAt: string;
}

// Get full board with all columns and cards
export async function getBoard(boardId: string): Promise<Board> {
  const { rows: [board] } = await pool.query("SELECT * FROM boards WHERE id = $1", [boardId]);
  if (!board) throw new Error("Board not found");

  const { rows: columns } = await pool.query(
    "SELECT * FROM columns WHERE board_id = $1 ORDER BY \"order\"",
    [boardId]
  );

  const { rows: cards } = await pool.query(
    `SELECT c.* FROM cards c
     JOIN columns col ON c.column_id = col.id
     WHERE col.board_id = $1
     ORDER BY c."order"`,
    [boardId]
  );

  // Group cards by column
  const cardsByColumn = new Map<string, Card[]>();
  for (const card of cards) {
    if (!cardsByColumn.has(card.column_id)) cardsByColumn.set(card.column_id, []);
    cardsByColumn.get(card.column_id)!.push(card);
  }

  return {
    id: board.id,
    name: board.name,
    columns: columns.map((col) => ({
      ...col,
      cards: cardsByColumn.get(col.id) || [],
    })),
  };
}

// Move card to a new position (within same column or across columns)
export async function moveCard(
  cardId: string,
  targetColumnId: string,
  newOrder: number,
  userId: string
): Promise<{ success: boolean; wipExceeded: boolean }> {
  // Check WIP limit on target column
  const { rows: [column] } = await pool.query(
    "SELECT wip_limit FROM columns WHERE id = $1",
    [targetColumnId]
  );

  if (column.wip_limit) {
    const { rows: [{ count }] } = await pool.query(
      "SELECT COUNT(*) as count FROM cards WHERE column_id = $1 AND id != $2",
      [targetColumnId, cardId]
    );
    if (parseInt(count) >= column.wip_limit) {
      return { success: false, wipExceeded: true };
    }
  }

  // Get current card state for history
  const { rows: [currentCard] } = await pool.query(
    "SELECT column_id, \"order\" FROM cards WHERE id = $1",
    [cardId]
  );

  // Update card position
  await pool.query(
    `UPDATE cards SET column_id = $2, "order" = $3, updated_at = NOW() WHERE id = $1`,
    [cardId, targetColumnId, newOrder]
  );

  // Record movement in activity log
  if (currentCard.column_id !== targetColumnId) {
    await pool.query(
      `INSERT INTO card_activity (card_id, user_id, action, from_column, to_column, created_at)
       VALUES ($1, $2, 'moved', $3, $4, NOW())`,
      [cardId, userId, currentCard.column_id, targetColumnId]
    );
  }

  // Broadcast change to all connected clients
  await redis.publish(`board:updates`, JSON.stringify({
    type: "card_moved",
    cardId,
    fromColumn: currentCard.column_id,
    toColumn: targetColumnId,
    newOrder,
    userId,
  }));

  return { success: true, wipExceeded: false };
}

// Calculate order value for inserting between two cards
export function calculateOrder(beforeOrder: number | null, afterOrder: number | null): number {
  if (beforeOrder === null && afterOrder === null) return 1000;  // first card
  if (beforeOrder === null) return afterOrder! / 2;               // before first
  if (afterOrder === null) return beforeOrder + 1000;             // after last

  // Insert between: use midpoint
  const midpoint = (beforeOrder + afterOrder) / 2;

  // If gap is too small (< 0.001), rebalance the column
  if (afterOrder - beforeOrder < 0.001) {
    return -1; // signal to caller: rebalance needed
  }

  return midpoint;
}

// Rebalance card ordering in a column (reset to integer sequence)
export async function rebalanceColumn(columnId: string): Promise<void> {
  const { rows: cards } = await pool.query(
    `SELECT id FROM cards WHERE column_id = $1 ORDER BY "order"`,
    [columnId]
  );

  for (let i = 0; i < cards.length; i++) {
    await pool.query(
      `UPDATE cards SET "order" = $2 WHERE id = $1`,
      [cards[i].id, (i + 1) * 1000]
    );
  }
}

// Create card
export async function createCard(
  columnId: string,
  title: string,
  userId: string,
  data?: Partial<Card>
): Promise<Card> {
  // Get max order in column
  const { rows: [{ max_order }] } = await pool.query(
    `SELECT COALESCE(MAX("order"), 0) as max_order FROM cards WHERE column_id = $1`,
    [columnId]
  );

  const id = `card-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
  const order = (parseFloat(max_order) || 0) + 1000;

  const { rows: [card] } = await pool.query(
    `INSERT INTO cards (id, column_id, title, description, "order", assignee_id, priority, labels, due_date, created_at, updated_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
     RETURNING *`,
    [id, columnId, title, data?.description || "", order,
     data?.assigneeId || null, data?.priority || "medium",
     JSON.stringify(data?.labels || []), data?.dueDate || null]
  );

  await redis.publish("board:updates", JSON.stringify({
    type: "card_created", card, userId,
  }));

  return card;
}

// WebSocket handler for real-time sync
export async function subscribeToBoard(boardId: string, onUpdate: (event: any) => void): Promise<() => void> {
  const subscriber = redis.duplicate();
  const channel = `board:updates`;

  await subscriber.subscribe(channel);
  subscriber.on("message", (ch, message) => {
    onUpdate(JSON.parse(message));
  });

  return () => {
    subscriber.unsubscribe(channel);
    subscriber.quit();
  };
}

Results

  • Task tracking spreadsheet eliminated — visual Kanban board shows workflow state at a glance; daily standup meetings shortened from 30 to 10 minutes
  • No more overwritten changes — real-time sync via WebSocket means all team members see moves instantly; optimistic UI updates make it feel instant
  • WIP limits enforce flow — "In Progress" column limited to 3 cards per person; team stops starting and starts finishing; cycle time dropped 40%
  • Fractional ordering — inserting a card between positions 2000 and 3000 gets order 2500; no need to reorder every card on every drag; O(1) move operations
  • Activity trail — every card movement is logged; managers see "this card was in Review for 5 days" and identify process bottlenecks