【AI駆動開発ブログ】「一覧じゃ流れが見えない」を消すために、React Flow + dagreで依存関係グラフを作った話

この記事の要点

  • 自社のプロジェクト管理ツール「WIP」(Rust + React、OSS公開済み)に、React Flow + dagreでタスク依存関係をグラフ表示する画面を追加しました
  • きっかけは「一覧やガントチャートでは、タスクの流れが全然わからない」という社内の愚痴でした
  • 実装の主役はReact Flowというライブラリの採用そのもの。既存のAPIを再利用しつつ、新規に増やしたAPIは1本だけです
  • 実装依頼書は「新規テーブルから作れ」という前提でしたが、調べたら必要なテーブルもAPIも既に存在していました
  • その日のうちに2つの小さなバグを踏み、同じ晩のうちにフィルター機能まで仕上げています

はじめに

Backlogとか、もう、一覧になってるかガントだから、流れが全然わからなくなる

社内の雑談チャットでこう愚痴ったのが、今回の機能追加の発端でした。既存のタスク管理ツールは大抵「一覧」か「ガントチャート」の二択で、タスク同士が実際どう繋がっているかという**「流れ」**を見せてはくれません。

行き着いた先が、React Flow + dagreで自動整列するタスク依存関係のフローグラフです。ノード同士をドラッグして繋ぐと依存関係になり、エッジをクリックすれば削除できます。実際の画面構成を、架空のチケットで再現すると次のようなイメージです(実データではありません)。

flowchart LR
    A["TST-1 認証基盤の設計
    🟡 in_progress · 5pt"]
    B["TST-2 JWTミドルウェア実装
    ⚪ backlog · 3pt"]
    C["TST-3 ログイン画面のUI実装
    ⚪ backlog · 2pt"]
    D["TST-4 パスキー登録フロー
    ⚪ backlog · 3pt"]
    E["TST-5 監査ログ出力
    🔵 resolved · 1pt"]
    F["TST-6 通知メール文面調整
    ⚪ backlog · 1pt"]

    E -->|blocks| A
    A -->|blocks| B
    A -->|blocks| D
    B -->|blocks| C
    D -->|blocks| C

ノードカードにはチケットキー・ステータス・担当者・ストーリーポイントを表示し、エッジにはblocksのような依存種別のラベルが付きます。図中のTST-6のように依存関係を1つも持たないノード(孤立ノード)は、実際の画面ではデフォルトで非表示になります(詳しくは後述)。

WIPには現在、この依存関係フローに加えて、Cycle(スプリント)単位のバーンダウン・スコープクリープ可視化、ガントチャートの計3種類の可視化機能があります。今回はこのうち依存関係フローに絞って書きます。ちなみにWIPはもともと「Linearを超える」を合言葉に作り始めたツールで、スプリントを表す機能名を「Cycle」と呼んでいたりと、設計思想の端々にLinearからの影響が色濃く出ています。

前回の記事「DjangoからRustへ、AIと二人三脚で移行してOSS公開するまで」では移行プロジェクト全体の流れを追いましたが、今回はその途中で実装したこの1機能に絞って、実装依頼から実装完了までの経緯を書きます。今回もClaude Codeとのペアプログラミングです。

きっかけ: なぜ「流れ」が見えないと困るのか

冒頭の愚痴には、具体的な失敗体験が伴っていました。

水道の時なんか、まさしく一覧出して、わけわからん!になってたじゃんw

以前担当した別案件で、WIPのチケット一覧をそのまま眺めても依存関係も順序も追えず、収拾がつかなくなったことがありました。一覧は「何があるか」は教えてくれても、「何から手をつければいいか」は教えてくれません。

ちょうど同じタイミングで、チームメンバーが自分の開発作業日記(Markdownファイル群)をAIに読み込ませ、ファイル同士のつながりをグラフ表示させたスクリーンショットを共有してくれました。「これって、思いっきり作業の可視化じゃん」という一言が、そのまま「WIPでも、この辺りの塩梅が可視化できるのはいいかも」という話に転がっていきます。

対象範囲もこの雑談の時点で固まっていました。

まずはプロジェクト単位で可視化されてないとね

理想は1スタック(作業ブロック)単位での可視化ですが、実装ではまずプロジェクト単位のグラフに絞っています。これは後述する詳細設計書のスコープとも一致しています。

なぜ依存関係「だけ」では足りないのか

同じ雑談の中で、こんなやり取りもありました。

作業には難易度みたいなものもあるから、それも可視化する。高難易度ー概ね2日、みたいなざっくりとした時間がわかってくる

矢印で繋いだ「流れ」だけでなく、その1つ1つのタスクがどれくらい重いのか(難易度・所要時間の目安)も一緒に見えて初めて、実用的な地図になるという指摘です。「タスクの重み付けをして、流れを可視化するやつね」という一言が、そのまま後の実装の芯になりました。

WIPには元々「ストーリーポイント」という項目があり、各チケットに1・2・3・5・8といった数値を設定できます。アジャイル開発でよく使われる、作業量を実時間ではなく相対的な大きさで見積もる指標で、「高難易度ー概ね2日」のようなざっくりした感覚を数値に落とし込んだものと言えます。目安にすると:

  • 1pt = 数時間で終わる細かい作業
  • 3pt = 半日〜1日仕事
  • 5pt = 数日がかりの作業
  • 8pt = 1週間近くかかる大きめの作業

前掲の図でいえば、TST-1(5pt)は数日がかりの重めのタスク、TST-3(2pt)は半日程度で終わる軽いタスクです。

矢印だけの依存関係グラフには、ここで問題が出ます。「AがBとDをブロックしている」という繋がりは分かっても、Aがどれくらいのボリュームなのかは開いてみないと分かりません。図で言えばTST-1(5pt)は、TST-2(3pt)とTST-4(3pt)の**合計6ptぶんの作業をせき止めている「重いブロッカー」**です。これが1ptの軽いタスクなら後回しにしても影響は小さいですが、5ptともなるとプロジェクト全体のボトルネックになります。

逆に、依存が解消されて複数タスクに同時着手できるようになった場面でも、重さの情報は効きます。似た規模感のタスクが並んでいればどちらから着手しても構いませんが、片方が1pt・もう片方が8ptのように差が大きければ、「軽い方から片付けて数を減らす」のか「重い方を先に着手して余裕を持たせる」のかで判断が変わってきます。繋がり(依存関係)と重さ(ポイント)は、同じ画面に無いと意味を持たないというのが、この時の指摘の要点でした。

実装の主役はReact Flow + dagre

バックエンド側の変更はごく小さく、DependencyGraphOut { nodes, edges }を返すハンドラを1つ追加しただけです。ノードのSELECTと担当者(多対多)のSELECTを分けて2クエリにし、Rust側でチケットIDをキーにグルーピングすることでN+1を回避しています。

一方、今回の実装で一番効いた判断は、フロントエンドで@xyflow/react(React Flow)を採用したことです。React Flowは、ノードとエッジで構成されるインタラクティブなキャンバスUIを組み立てるためのライブラリで、次のようなものをほぼそのまま提供してくれます。

  • パン・ズーム
  • ノードのドラッグ移動
  • ノード間をドラッグして繋ぐ接続操作
  • ミニマップ・コントロールパネル

ノードの見た目はHandle/Positionを使ったカスタムコンポーネント(TaskNode.tsx)で自由に作り込めるため、チケットキー・ステータスバッジ・担当者・ストーリーポイントを詰め込んだカードを、そのままノードとして描画できました。

React Flow自身は「ノードをどこに配置するか」というレイアウト計算まではしてくれません。そこを補うのがdagreです。

function layoutWithDagre(nodes: Node[], edges: Edge[]): Node[] {
  const g = new dagre.graphlib.Graph();
  g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 100 });
  nodes.forEach((n) => g.setNode(n.id, { width: 260, height: 90 }));
  edges.forEach((e) => g.setEdge(e.source, e.target));
  dagre.layout(g);
  return nodes.map((n) => ({ ...n, position: g.node(n.id) }));
}

ノードとエッジの接続情報を渡すだけで、依存関係が左→右に流れるように自動整列した座標を計算してくれます。フィルターで表示ノードが変わるたびにこの関数を呼び直し、その座標をReact Flowに渡すだけで再レイアウトが完了します。ドラッグ接続やエッジクリック削除も、既存のPOST /tickets/{ticket_key}/dependencies/DELETE .../dependencies/{dep_id}/を呼ぶだけで、新しいAPIを1つも増やさずに実現できました。

この組み合わせのおかげで、TaskDependencyFlow.tsx1ファイル(160行程度)で「使えるグラフUI」が完成しています。CanvasやSVGを直接叩いて自作していたら、この規模には到底収まらなかったはずです。

E2Eテストは、社内で使っているOllama(ローカルLLM)によるビジュアル検証の仕組みに3シナリオ追加しました。テストDSLにドラッグ操作の命令が無かったため、依存関係の作成・削除はブラウザのXHRを直接叩いて再現し、画面をリロードしてラベルの出現・消失をLLMに確認させる形にしています。実際のドラッグ操作自体は手動で別途確認しました。

依頼書は「新規テーブルを作れ」だったが…

実装に入る前、AIが現状のコードベースを調べたところ、最初の実装依頼書の前提が実態と食い違っていることが分かりました。

  • 依頼書は新規テーブル・新規ファイル一式・独自URL体系・共通AppError型・Tailwind CSSを前提にしていた
  • しかし依存関係のテーブル(t_task_dependency)も作成・削除APIも、Django時代から既に存在していた(フロントエンドから一度も呼ばれないまま眠っていただけ)
  • AppError型もTailwindも、このリポジトリには元々存在しない

つまり依頼書通りに作ると、既にあるテーブルとAPIをもう一つ余分に作ってしまうところでした。 調査結果を受けて設計書のスコープを絞り直し、本当に足りなかった「プロジェクト単位の一括取得API1本」と「それを描画する画面」だけを実装対象にしています。

並行開発との衝突をどう避けたか

もう一つ厄介だったのが、同じタイミングで別の機能(Cycleのスコープクリープ可視化・自動進行)が裏で並行実装されていて、今回も触る予定のticket_repo.rstypes.tsに未コミットの変更が既に入っていたことです。

設計書には、衝突を避けるための指示を具体的に書きました。

  • ticket_repo.rsの1707〜2160行付近はCycles側が変更中なので触らない。依存関係セクションはファイル末尾(2380行以降)に追記するので衝突しない
  • types.tsの246行目以降はCycles側が変更中。型定義は225行目付近、TaskDependencyの直後に挿入するので衝突しない
  • ただし実装着手の直前に、必ずgit diffでファイル末尾の実際の行数を再確認する(並行開発で行数がずれている可能性があるため)

行番号を指示に埋め込むだけでなく「着手前に現物を再確認しろ」まで明記しないと、もう一方の変更で行番号がずれた瞬間に無関係な行を壊しかねません。実際この方針のおかげで、2つの機能は同じファイルの別の場所にきれいに追記され、マージ時のコンフリクトは発生しませんでした。

その日のうちに踏んだ2つの小さなバグ

実装したその日のうちに、2つの不具合を踏んで直しています。

① CIのビルドだけで落ちる型エラー

ノードのデータにDependencyGraphNodeをそのまま渡していた箇所が、ローカルのtsc --noEmitでは検出されず、CIが実行するtsc -b(プロジェクト参照ビルド)でだけ型エラーになりました。

- data: n,
+ data: n as TaskNodeData,

TaskNodeData側に付けていたインデックスシグネチャ([key: string]: unknown)との構造的な非互換が、ビルドモードの違いで見え方が変わったのが原因です。ローカルでnpm run build(CIと同一コマンド)を実行して再現させ、明示キャストで解消しました。

② 接続ハンドルが小さすぎて掴みにくい

React Flowのノードから伸びる接続用ハンドルは見た目上8pxの丸ですが、実際に触るとドラッグの起点として小さすぎて掴めませんでした。見た目の丸のサイズは変えず、::after擬似要素で上下左右10pxずつ広げた見えない当たり判定を追加し、近づくとカーソルがcrosshairに変わるようにしています。

その晩のうちに仕上げた種別フィルター

一通り動くようになった後、同じ晩のうちにもう一段仕上げています。

  • 依存関係が全く無い「孤立ノード」をデフォルトで非表示にし、チェックボックスで表示切り替え可能に
  • ticket_type(issue/feature/improvement/task)によるノード絞り込みを追加
  • フィルターを変更するたびにdagreで再レイアウト

バックエンド側の変更はDependencyGraphNodeOutticketTypeフィールドを1つ追加しただけで、新しいテーブルもAPIも増やしていません。孤立ノードの判定はエッジのsource/target集合に含まれないノードをフロントエンド側で集計しているだけで、こちらもサーバー側の変更は不要でした。

おわりに

この機能で技術的に一番効いたのは、やはりReact Flow + dagreという組み合わせでした。パン・ズーム・ドラッグ接続・自動整列という「グラフを触る」ための土台をライブラリに任せられたことで、バックエンドは既存資産の再利用に徹し、フロントエンドもノードの見た目とデータ取得フックを書くだけで済んでいます。ゼロから作り込んでいたら、この規模には到底収まりませんでした。

その手前で「依頼書に書いてあることを鵜呑みにせず、まず現状を調べる」という一手を挟んだのも、スコープを絞り込む上で効いています。前回の記事に書いたOSS公開時の一件(ドキュメントの日付が古く、実態と乖離していた話)と根っこは同じで、「書いてあるから正しい」を疑って裏取りする姿勢が、既存資産の二重実装を未然に防いだ形です。

WIPは引き続きOSSとして公開中です。バグ報告やフィードバック、Star ⭐ をいただけると励みになります。

https://github.com/macplanning-labs/wip