第9部:実践プロジェクト Step 34 / 36

実装フェーズ2:高度な機能

ドラッグ&ドロップ、リアルタイム同期など、高度な機能をClaude Codeで実装します。

Phase 2 で実装する機能

1 dnd-kitによるドラッグ&ドロップ
2 WebSocketリアルタイム同期
3 楽観的更新(Optimistic Update)
4 チームメンバー招待機能

ステップ1:dnd-kitによるドラッグ&ドロップ

Claude Codeへの指示

@dnd-kit/core と @dnd-kit/sortable を使って、カンバンボードの
ドラッグ&ドロップを実装してください。

【要件】
- 3つのカラム(todo / in_progress / done)間でタスクを移動
- 同一カラム内でタスクの順序を変更可能
- ドロップ時にバックエンドAPIを呼んでステータスと順序を更新
- ドラッグ中のビジュアルフィードバック(半透明+シャドウ)
- モバイルでもタッチ操作でドラッグ可能

【技術スタック】
- @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities
- 既存の TanStack Query によるタスク取得と連携
- PATCH /tasks/{id} でステータス・順序を更新

段階的に実装してください:
1. まずパッケージインストールと基本構造
2. カラム間のドラッグ&ドロップ
3. カラム内の並び替え
4. API連携とエラーハンドリング

Claude Codeが行うこと

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities を実行
frontend/src/components/kanban/KanbanBoard.tsx を作成
frontend/src/components/kanban/SortableColumn.tsx を作成
frontend/src/components/kanban/SortableTaskCard.tsx を作成
frontend/src/hooks/useTaskDragDrop.ts を作成
☑ 既存の ProjectPage.tsx にカンバンボードを統合

完成コード:KanbanBoard コンポーネント

// frontend/src/components/kanban/KanbanBoard.tsx
import { DndContext, DragOverlay, closestCorners,
         PointerSensor, TouchSensor, useSensors, useSensor } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useState } from 'react';
import { SortableColumn } from './SortableColumn';
import { TaskCard } from './TaskCard';
import { useTaskDragDrop } from '@/hooks/useTaskDragDrop';

const COLUMNS = [
  { id: 'todo', title: 'Todo', color: 'bg-gray-100' },
  { id: 'in_progress', title: 'In Progress', color: 'bg-blue-50' },
  { id: 'done', title: 'Done', color: 'bg-green-50' },
];

export function KanbanBoard({ projectId }: { projectId: string }) {
  const { tasks, handleDragStart, handleDragOver,
          handleDragEnd, activeTask } = useTaskDragDrop(projectId);

  // マウスとタッチの両方に対応
  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: { distance: 8 },
    }),
    useSensor(TouchSensor, {
      activationConstraint: { delay: 200, tolerance: 5 },
    }),
  );

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCorners}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      onDragEnd={handleDragEnd}
    >
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
        {COLUMNS.map((column) => (
          <SortableColumn
            key={column.id}
            column={column}
            tasks={tasks.filter(t => t.status === column.id)}
          />
        ))}
      </div>

      {/* ドラッグ中のオーバーレイ表示 */}
      <DragOverlay>
        {activeTask ? <TaskCard task={activeTask} isDragging /> : null}
      </DragOverlay>
    </DndContext>
  );
}

完成コード:SortableTaskCard コンポーネント

// frontend/src/components/kanban/SortableTaskCard.tsx
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { TaskCard } from './TaskCard';
import type { Task } from '@/types';

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

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

  return (
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      <TaskCard task={task} isDragging={isDragging} />
    </div>
  );
}

✓ 動作確認

  1. ブラウザで http://localhost:3000 を開く
  2. プロジェクトを選択してカンバンボードを表示
  3. タスクカードをドラッグして別のカラムにドロップ
  4. ステータスが変更されることを確認
  5. 同じカラム内でタスクの順序を入れ替え
  6. ページをリロードして変更が永続化されていることを確認

注意点

  • @dnd-kit/sortable は SortableContext の中でのみ動作します
  • activationConstraint を設定しないと、クリックイベントとドラッグが競合します
  • DragOverlay を使うと、ドラッグ中の要素が元の位置から分離して表示されます

ステップ2:WebSocketリアルタイム同期

Claude Codeへの指示

WebSocketでリアルタイム同期を実装してください。

【バックエンド要件】
- FastAPIのWebSocketエンドポイント(/ws/{project_id})
- Redis Pub/Sub でメッセージをブロードキャスト
- ConnectionManager クラスでルーム管理(プロジェクト単位)
- 接続/切断時のユーザー通知
- JWTトークンによるWebSocket認証

【フロントエンド要件】
- useWebSocket カスタムフックを作成
- 自動再接続ロジック(指数バックオフ)
- WebSocketメッセージ受信時に TanStack Query のキャッシュを無効化
- 接続状態のインジケーター表示

【メッセージ形式】
{
  "type": "task_created" | "task_updated" | "task_deleted",
  "payload": { ... },
  "user_id": "送信者ID"
}

Claude Codeが行うこと

backend/app/websocket/manager.py を作成(ConnectionManager)
backend/app/websocket/routes.py を作成(WebSocketエンドポイント)
backend/app/services/notification.py を作成(Redis Pub/Sub)
frontend/src/hooks/useWebSocket.ts を作成
frontend/src/components/ConnectionStatus.tsx を作成
☑ 既存のタスクCRUDにWebSocket通知を追加

完成コード:ConnectionManager(バックエンド)

# backend/app/websocket/manager.py
from fastapi import WebSocket
from typing import Dict, Set
import json

class ConnectionManager:
    """プロジェクト単位のWebSocket接続を管理"""

    def __init__(self):
        # { project_id: { user_id: WebSocket } }
        self.rooms: Dict[str, Dict[str, WebSocket]] = {}

    async def connect(
        self, websocket: WebSocket,
        project_id: str, user_id: str
    ):
        await websocket.accept()
        if project_id not in self.rooms:
            self.rooms[project_id] = {}
        self.rooms[project_id][user_id] = websocket

        # 他のメンバーに参加を通知
        await self.broadcast(project_id, {
            "type": "user_joined",
            "payload": {"user_id": user_id},
        }, exclude=user_id)

    async def disconnect(self, project_id: str, user_id: str):
        if project_id in self.rooms:
            self.rooms[project_id].pop(user_id, None)
            if not self.rooms[project_id]:
                del self.rooms[project_id]

        # 他のメンバーに退出を通知
        await self.broadcast(project_id, {
            "type": "user_left",
            "payload": {"user_id": user_id},
        })

    async def broadcast(
        self, project_id: str,
        message: dict, exclude: str = None
    ):
        """プロジェクト内の全接続にメッセージを送信"""
        if project_id not in self.rooms:
            return
        for uid, ws in self.rooms[project_id].items():
            if uid != exclude:
                try:
                    await ws.send_json(message)
                except Exception:
                    # 送信失敗時は切断扱い
                    await self.disconnect(project_id, uid)

manager = ConnectionManager()

完成コード:useWebSocket フック(フロントエンド)

// frontend/src/hooks/useWebSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';

type ConnectionState = 'connecting' | 'connected' | 'disconnected';

export function useWebSocket(projectId: string, token: string) {
  const wsRef = useRef<WebSocket | null>(null);
  const queryClient = useQueryClient();
  const [state, setState] = useState<ConnectionState>('disconnected');
  const retryCount = useRef(0);

  const connect = useCallback(() => {
    const ws = new WebSocket(
      `ws://localhost:8000/ws/${projectId}?token=${token}`
    );

    ws.onopen = () => {
      setState('connected');
      retryCount.current = 0;
    };

    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);

      // メッセージタイプに応じてキャッシュを無効化
      switch (data.type) {
        case 'task_created':
        case 'task_updated':
        case 'task_deleted':
          queryClient.invalidateQueries({
            queryKey: ['tasks', projectId],
          });
          break;
        case 'user_joined':
        case 'user_left':
          queryClient.invalidateQueries({
            queryKey: ['members', projectId],
          });
          break;
      }
    };

    ws.onclose = () => {
      setState('disconnected');
      // 指数バックオフで再接続
      const delay = Math.min(1000 * 2 ** retryCount.current, 30000);
      retryCount.current += 1;
      setTimeout(connect, delay);
    };

    wsRef.current = ws;
  }, [projectId, token, queryClient]);

  useEffect(() => {
    connect();
    return () => wsRef.current?.close();
  }, [connect]);

  return { state, ws: wsRef.current };
}

✓ 動作確認

  1. ブラウザで2つのタブを開き、同じプロジェクトページを表示
  2. タブAでタスクを新規作成
  3. タブBにリアルタイムでタスクが表示されることを確認
  4. タブBでタスクのステータスを変更し、タブAに反映されることを確認
  5. 接続状態インジケーターが「接続中」と表示されていることを確認
  6. バックエンドを一時停止し、再起動後に自動再接続されることを確認

よくあるエラー

  • WebSocket connection failed - CORSの設定を確認。FastAPIの allow_origins にフロントエンドのURLを追加
  • Token validation error - WebSocketのクエリパラメータでトークンを渡す場合、HTTPヘッダーは使えないため注意
  • メモリリーク - コンポーネントのアンマウント時に ws.close() を忘れずに呼ぶ

ステップ3:楽観的更新(Optimistic Update)

楽観的更新とは?

APIレスポンスを待たずにUIを即座に更新し、エラーが発生した場合のみ元の状態にロールバックする手法です。ドラッグ&ドロップなど、即座のフィードバックが重要な操作で効果を発揮します。

通常の更新

操作 → API通信 → レスポンス → UI更新

体感:遅い(200-500ms待ち)

楽観的更新

操作 → UI即更新 → API通信 → 確認/ロールバック

体感:瞬時(0ms待ち)

Claude Codeへの指示

TanStack Query を使った楽観的更新を実装してください。

【対象操作】
- タスクのステータス変更(ドラッグ&ドロップ連動)
- タスクのタイトル・説明の編集
- タスクの削除

【要件】
- useMutation の onMutate でキャッシュを即時更新
- onError で前の状態にロールバック
- onSettled で最新データを再取得
- エラー時はトースト通知でユーザーに知らせる
- 自分の操作はWebSocket通知を無視(二重更新防止)

Claude Codeが行うこと

frontend/src/hooks/useUpdateTask.ts を作成
frontend/src/hooks/useDeleteTask.ts を作成
☑ 既存の useTaskDragDrop.ts に楽観的更新を統合
☑ トースト通知コンポーネントを追加

完成コード:楽観的更新付き useMutation

// frontend/src/hooks/useUpdateTask.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { toast } from '@/components/Toast';
import type { Task } from '@/types';

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

  return useMutation({
    mutationFn: (data: { taskId: string; updates: Partial<Task> }) =>
      api.patch(`/tasks/${data.taskId}`, data.updates),

    // 楽観的更新:APIレスポンス前にキャッシュを更新
    onMutate: async (data) => {
      // 進行中のクエリをキャンセル(競合防止)
      await queryClient.cancelQueries({
        queryKey: ['tasks', projectId],
      });

      // 現在のキャッシュを保存(ロールバック用)
      const previousTasks = queryClient.getQueryData<Task[]>(
        ['tasks', projectId]
      );

      // キャッシュを即座に更新
      queryClient.setQueryData<Task[]>(
        ['tasks', projectId],
        (old) => old?.map((task) =>
          task.id === data.taskId
            ? { ...task, ...data.updates }
            : task
        ) ?? []
      );

      return { previousTasks };
    },

    // エラー時:保存した状態にロールバック
    onError: (err, data, context) => {
      if (context?.previousTasks) {
        queryClient.setQueryData(
          ['tasks', projectId],
          context.previousTasks
        );
      }
      toast.error('タスクの更新に失敗しました');
    },

    // 完了時(成功/失敗問わず):最新データを再取得
    onSettled: () => {
      queryClient.invalidateQueries({
        queryKey: ['tasks', projectId],
      });
    },
  });
}

✓ 動作確認

  1. タスクをドラッグ&ドロップし、即座にUIが更新されることを確認
  2. DevToolsの Network タブを開き、API通信中もUIが先に反応していることを確認
  3. バックエンドを停止した状態でタスクをドラッグし、エラー後に元の位置に戻ることを確認
  4. エラー時にトースト通知が表示されることを確認

注意点

  • cancelQueries を忘れると、古いデータで上書きされる競合が発生します
  • WebSocketとの二重更新 - 自分が操作したタスクのWebSocket通知は無視するロジックが必要です
  • 複数のmutation が同時に走る場合、onMutate で保存するキャッシュが古くなる可能性があります

ステップ4:チームメンバー招待機能

Claude Codeへの指示

プロジェクトにチームメンバーを招待する機能を実装してください。

【バックエンドAPI】
- POST /projects/{id}/invite
  - メールアドレスで招待(既存ユーザーは即追加、未登録は招待メール送信)
  - ロール指定(admin / member / viewer)
- GET /projects/{id}/members
  - メンバー一覧を取得(ロール、参加日を含む)
- DELETE /projects/{id}/members/{user_id}
  - メンバーを削除(adminのみ可)
- PATCH /projects/{id}/members/{user_id}
  - ロールを変更(adminのみ可)

【フロントエンド】
- メンバー管理モーダル(プロジェクト設定から開く)
- メールアドレス入力+ロール選択の招待フォーム
- メンバー一覧(アバター、名前、ロール、参加日)
- ロール変更ドロップダウン
- メンバー削除(確認ダイアログ付き)

【権限制御】
- adminのみ招待・削除・ロール変更が可能
- memberはタスクのCRUDが可能
- viewerは閲覧のみ

Claude Codeが行うこと

backend/app/models/project_member.py を作成
backend/app/api/members.py を作成(APIエンドポイント)
backend/app/services/invite.py を作成(招待ロジック)
frontend/src/components/members/MemberModal.tsx を作成
frontend/src/components/members/InviteForm.tsx を作成
frontend/src/components/members/MemberList.tsx を作成
☑ DBマイグレーションファイルを生成

完成コード:招待APIエンドポイント

# backend/app/api/members.py
from fastapi import APIRouter, Depends, HTTPException
from app.models.project_member import ProjectMember, Role
from app.services.invite import InviteService
from app.auth.deps import get_current_user, require_admin

router = APIRouter()

@router.post("/projects/{project_id}/invite")
async def invite_member(
    project_id: str,
    invite: InviteRequest,
    current_user = Depends(require_admin),
    invite_service: InviteService = Depends(),
):
    """メンバーを招待"""
    result = await invite_service.invite(
        project_id=project_id,
        email=invite.email,
        role=invite.role,
        invited_by=current_user.id,
    )
    return result

@router.get("/projects/{project_id}/members")
async def list_members(
    project_id: str,
    current_user = Depends(get_current_user),
):
    """メンバー一覧を取得"""
    members = await ProjectMember.filter(
        project_id=project_id
    ).prefetch_related("user").all()

    return [
        {
            "id": m.user.id,
            "name": m.user.name,
            "email": m.user.email,
            "role": m.role,
            "joined_at": m.created_at,
        }
        for m in members
    ]

@router.delete("/projects/{project_id}/members/{user_id}")
async def remove_member(
    project_id: str,
    user_id: str,
    current_user = Depends(require_admin),
):
    """メンバーを削除"""
    if user_id == current_user.id:
        raise HTTPException(400, "自分自身は削除できません")
    deleted = await ProjectMember.filter(
        project_id=project_id, user_id=user_id
    ).delete()
    if not deleted:
        raise HTTPException(404, "メンバーが見つかりません")
    return {"message": "メンバーを削除しました"}

完成コード:MemberList コンポーネント

// frontend/src/components/members/MemberList.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { Member, Role } from '@/types';

const ROLE_LABELS: Record<Role, string> = {
  admin: '管理者',
  member: 'メンバー',
  viewer: '閲覧者',
};

export function MemberList({
  projectId, isAdmin,
}: {
  projectId: string;
  isAdmin: boolean;
}) {
  const queryClient = useQueryClient();

  const { data: members = [] } = useQuery({
    queryKey: ['members', projectId],
    queryFn: () =>
      api.get<Member[]>(`/projects/${projectId}/members`),
  });

  const removeMutation = useMutation({
    mutationFn: (userId: string) =>
      api.delete(`/projects/${projectId}/members/${userId}`),
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ['members', projectId],
      });
    },
  });

  return (
    <div className="divide-y">
      {members.map((member) => (
        <div key={member.id}
             className="flex items-center justify-between py-3">
          <div className="flex items-center gap-3">
            <div className="w-8 h-8 bg-purple-100
                            rounded-full flex items-center
                            justify-center text-sm font-bold
                            text-purple-600">
              {member.name[0]}
            </div>
            <div>
              <p className="font-medium">{member.name}</p>
              <p className="text-sm text-gray-500">
                {member.email}
              </p>
            </div>
          </div>
          <div className="flex items-center gap-2">
            <span className="text-sm px-2 py-1 bg-gray-100
                            rounded">
              {ROLE_LABELS[member.role]}
            </span>
            {isAdmin && member.role !== 'admin' && (
              <button
                onClick={() => removeMutation.mutate(member.id)}
                className="text-red-500 text-sm hover:underline"
              >
                削除
              </button>
            )}
          </div>
        </div>
      ))}
    </div>
  );
}

✓ 動作確認

  1. プロジェクト設定からメンバー管理モーダルを開く
  2. メールアドレスを入力してメンバーを招待
  3. 招待されたユーザーがメンバー一覧に表示されることを確認
  4. ロールの変更が正しく動作することを確認
  5. メンバーの削除が確認ダイアログ付きで動作することを確認
  6. viewer ロールのユーザーでログインし、タスクの編集ができないことを確認
  7. member ロールのユーザーでログインし、招待・削除ができないことを確認

注意点

  • 自分自身の削除防止 - adminが自分を削除するとプロジェクトが管理不能になります
  • 最後のadmin保護 - プロジェクトに最低1人のadminが必要です
  • フロントエンドの権限表示 - APIの権限チェックだけでなく、UIでもボタンの表示/非表示を切り替えましょう

Phase 2 まとめ

  • ドラッグ&ドロップ - dnd-kitでカンバンボードの操作性を向上
  • リアルタイム同期 - WebSocket + Redis Pub/Sub でチーム全体に即時反映
  • 楽観的更新 - TanStack Query の onMutate/onError パターンで瞬時のフィードバック
  • チーム管理 - ロールベースのアクセス制御でセキュアなコラボレーション

Claude Codeの活用ポイント

  • ・複数ファイルにまたがる機能は、要件を明確にして一度に依頼する
  • ・エラーが出たらそのまま貼り付けて修正を依頼する
  • ・各機能の実装後にコミットし、問題があればロールバックできるようにする
実装フェーズ1 次へ:テスト・CI/CD