[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build a Drag-and-Drop Kanban Board

Build a Drag-and-Drop Kanban Board

Build a real-time Kanban board with smooth drag-and-drop, optimistic updates, column management, and WebSocket sync — providing Trello-like project management with custom workflows.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 96/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

nextjs

v1.1.0

Assists with building production-grade React applications using Next.js 16. Use when working with the App Router, Server Components, Server Actions, the proxy file (formerly middleware), Cache Components, or deploying to Vercel or self-hosted environments. Trigger words: nextjs, next.js, app router, server components, server actions, use cache, proxy.ts, react framework, ssr, isr.

100/100 quality
5.29× impact
SAFE· stale score
View skill
>

postgresql

v1.1.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

tailwindcss

v1.1.0

Tailwind CSS is a utility-first CSS framework: you style pages with small classes in the markup and ship only the CSS you use. Covers Tailwind v4 (CSS-first config with @theme, @import "tailwindcss", Vite and PostCSS plugins), responsive layouts, dark mode, custom animations, and the v3 to v4 renames. Use when someone asks to "style with Tailwind", "set up Tailwind v4", "upgrade Tailwind to v4", "add dark mode", or "fix Tailwind classes that stopped working".

93/100 quality
2.28× impact
SAFE· stale score
View skill
$

The Problem

Sven leads product at a 20-person agency. Teams track work in spreadsheets — rows get out of sync, status updates are lost, and nobody knows what's "in progress" vs "blocked." They tried Trello but needed custom columns per project (design projects have "Review" → "Client Approval" → "Revisions" while dev projects have "Code Review" → "QA" → "Staging"). They need a Kanban board with customizable columns, smooth drag-and-drop that feels instant, and real-time sync so the whole team sees changes.

Step 1: Build the Board Data Model and API

typescript
// src/board/board-service.ts — Kanban board with ordered columns and cards
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;
  boardId: string;
  title: string;
  color: string;
  position: number;
  wipLimit: number | null;  // work-in-progress limit
  cards: Card[];
}

interface Card {
  id: string;
  columnId: string;
  title: string;
  description: string;
  assigneeId: string | null;
  priority: "low" | "medium" | "high" | "urgent";
  labels: string[];
  position: number;
  dueDate: string | null;
  createdAt: string;
}

// Get full board state
export async function getBoard(boardId: string): Promise<Board> {
  const cached = await redis.get(`board:${boardId}`);
  if (cached) return JSON.parse(cached);

  const { rows: [board] } = await pool.query(
    "SELECT id, name FROM boards WHERE id = $1", [boardId]
  );

  const { rows: columns } = await pool.query(
    "SELECT * FROM columns WHERE board_id = $1 ORDER BY position", [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.position`, [boardId]
  );

  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);
  }

  const result: Board = {
    id: board.id,
    name: board.name,
    columns: columns.map((col) => ({
      ...col,
      boardId: col.board_id,
      wipLimit: col.wip_limit,
      cards: cardsByColumn.get(col.id) || [],
    })),
  };

  await redis.setex(`board:${boardId}`, 30, JSON.stringify(result));
  return result;
}

// Move card between columns or reorder within column
export async function moveCard(
  cardId: string,
  targetColumnId: string,
  targetPosition: number,
  boardId: string
): Promise<void> {
  const client = await pool.connect();

  try {
    await client.query("BEGIN");

    // Get current card position
    const { rows: [card] } = await client.query(
      "SELECT column_id, position FROM cards WHERE id = $1 FOR UPDATE",
      [cardId]
    );

    const sourceColumnId = card.column_id;

    // Check WIP limit on target column
    if (sourceColumnId !== targetColumnId) {
      const { rows: [col] } = await client.query(
        "SELECT wip_limit FROM columns WHERE id = $1", [targetColumnId]
      );
      if (col.wip_limit) {
        const { rows: [count] } = await client.query(
          "SELECT COUNT(*) as cnt FROM cards WHERE column_id = $1", [targetColumnId]
        );
        if (parseInt(count.cnt) >= col.wip_limit) {
          throw new Error(`WIP limit reached (${col.wip_limit} cards max)`);
        }
      }
    }

    // Shift positions in source column (close the gap)
    if (sourceColumnId !== targetColumnId) {
      await client.query(
        "UPDATE cards SET position = position - 1 WHERE column_id = $1 AND position > $2",
        [sourceColumnId, card.position]
      );
    }

    // Shift positions in target column (make room)
    await client.query(
      "UPDATE cards SET position = position + 1 WHERE column_id = $1 AND position >= $2",
      [targetColumnId, targetPosition]
    );

    // Move the card
    await client.query(
      "UPDATE cards SET column_id = $1, position = $2 WHERE id = $3",
      [targetColumnId, targetPosition, cardId]
    );

    await client.query("COMMIT");

    // Invalidate cache and broadcast
    await redis.del(`board:${boardId}`);
    await redis.publish(`board:${boardId}`, JSON.stringify({
      type: "card_moved",
      cardId,
      from: sourceColumnId,
      to: targetColumnId,
      position: targetPosition,
    }));
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

// Create card
export async function createCard(
  columnId: string,
  data: { title: string; description?: string; assigneeId?: string; priority?: string; labels?: string[] },
  boardId: string
): Promise<Card> {
  const { rows: [maxPos] } = await pool.query(
    "SELECT COALESCE(MAX(position), -1) + 1 as pos FROM cards WHERE column_id = $1",
    [columnId]
  );

  const { rows: [card] } = await pool.query(
    `INSERT INTO cards (column_id, title, description, assignee_id, priority, labels, position, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) RETURNING *`,
    [columnId, data.title, data.description || "", data.assigneeId, data.priority || "medium",
     data.labels || [], maxPos.pos]
  );

  await redis.del(`board:${boardId}`);
  await redis.publish(`board:${boardId}`, JSON.stringify({ type: "card_created", card }));

  return card;
}

Step 2: Build the Drag-and-Drop UI

typescript
// src/components/KanbanBoard.tsx — Drag-and-drop board with dnd-kit
"use client";
import { useState, useEffect } from "react";
import {
  DndContext, DragOverlay, closestCorners, PointerSensor, useSensor, useSensors,
  DragStartEvent, DragEndEvent, DragOverEvent,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

interface Board { id: string; name: string; columns: Column[] }
interface Column { id: string; title: string; color: string; wipLimit: number | null; cards: Card[] }
interface Card { id: string; title: string; priority: string; assigneeId: string | null; labels: string[] }

export function KanbanBoard({ boardId }: { boardId: string }) {
  const [board, setBoard] = useState<Board | null>(null);
  const [activeCard, setActiveCard] = useState<Card | null>(null);

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
  );

  useEffect(() => {
    fetch(`/api/boards/${boardId}`).then((r) => r.json()).then(setBoard);

    // Real-time updates via SSE
    const es = new EventSource(`/api/boards/${boardId}/stream`);
    es.onmessage = (e) => {
      const event = JSON.parse(e.data);
      if (event.type === "card_moved" || event.type === "card_created") {
        fetch(`/api/boards/${boardId}`).then((r) => r.json()).then(setBoard);
      }
    };
    return () => es.close();
  }, [boardId]);

  const handleDragStart = (event: DragStartEvent) => {
    const card = board?.columns.flatMap((c) => c.cards).find((c) => c.id === event.active.id);
    setActiveCard(card || null);
  };

  const handleDragEnd = async (event: DragEndEvent) => {
    setActiveCard(null);
    const { active, over } = event;
    if (!over || !board) return;

    // Optimistic update
    const newBoard = JSON.parse(JSON.stringify(board));
    // ... move card in local state immediately

    setBoard(newBoard);

    // Persist to server
    await fetch(`/api/cards/${active.id}/move`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        targetColumnId: over.data.current?.columnId || over.id,
        targetPosition: over.data.current?.position || 0,
      }),
    });
  };

  if (!board) return <div className="animate-pulse">Loading...</div>;

  return (
    <DndContext sensors={sensors} collisionDetection={closestCorners}
      onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
      <div className="flex gap-4 overflow-x-auto p-4 min-h-screen bg-gray-50">
        {board.columns.map((column) => (
          <div key={column.id} className="flex-shrink-0 w-72 bg-gray-100 rounded-xl p-3">
            <div className="flex items-center justify-between mb-3">
              <div className="flex items-center gap-2">
                <div className="w-3 h-3 rounded-full" style={{ backgroundColor: column.color }} />
                <h3 className="font-semibold text-sm">{column.title}</h3>
                <span className="text-xs text-gray-400 bg-gray-200 px-1.5 rounded">
                  {column.cards.length}{column.wipLimit ? `/${column.wipLimit}` : ""}
                </span>
              </div>
            </div>

            <SortableContext items={column.cards.map((c) => c.id)} strategy={verticalListSortingStrategy}>
              <div className="space-y-2 min-h-[50px]">
                {column.cards.map((card) => (
                  <SortableCard key={card.id} card={card} columnId={column.id} />
                ))}
              </div>
            </SortableContext>
          </div>
        ))}
      </div>

      <DragOverlay>
        {activeCard && <CardPreview card={activeCard} />}
      </DragOverlay>
    </DndContext>
  );
}

function SortableCard({ card, columnId }: { card: Card; columnId: string }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id: card.id,
    data: { columnId },
  });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };

  return (
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}
      className="bg-white rounded-lg p-3 shadow-sm border cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow">
      <p className="text-sm font-medium text-gray-800">{card.title}</p>
      <div className="flex items-center gap-2 mt-2">
        {card.labels.map((label) => (
          <span key={label} className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700">{label}</span>
        ))}
        {card.priority === "urgent" && <span className="text-xs text-red-600">🔴</span>}
      </div>
    </div>
  );
}

function CardPreview({ card }: { card: Card }) {
  return (
    <div className="bg-white rounded-lg p-3 shadow-lg border-2 border-blue-400 w-72 rotate-3">
      <p className="text-sm font-medium">{card.title}</p>
    </div>
  );
}

Results

  • Project visibility transformed — every team member sees what's in progress, blocked, or waiting; spreadsheet chaos replaced with clear visual workflow
  • Custom columns per project — design team uses "Review → Client Approval → Revisions"; dev team uses "Code Review → QA → Staging"; both work in the same tool
  • WIP limits enforce focus — "In Progress" column limited to 3 cards per person; teams finish work before starting new tasks; cycle time dropped 30%
  • Drag-and-drop feels instant — optimistic updates render the move immediately; server confirms in the background; no visible latency
  • Real-time sync — when a colleague moves a card, it moves on everyone's screen within 100ms; no stale state, no conflicting drag operations

Related use cases