🔄

Tauri 2.0 + React + SQLiteで作るプロセスフロー可視化アプリ開発記

に公開

Tauri 2.0 + React + SQLiteで作るプロセスフロー可視化プロジェクト管理アプリ

はじめに

プロジェクト管理ツールは数多く存在しますが、タスク間の依存関係をビジュアルに把握できるツールは意外と少ないものです。本記事では、Tauri 2.0をベースに、プロセスフローダイアグラムでタスクと成果物の関係を視覚化できるデスクトップアプリ「AIR-Project」の開発で得た知見を共有します。

この記事で学べること

  • Tauri 2.0の最新機能を使ったデスクトップアプリ開発
  • SQLiteプラグインによるローカルデータベースの実装
  • React Flowを使ったインタラクティブなダイアグラム機能
  • Tauriアプリでのファイル入出力とエクスポート機能の実装

プロジェクト概要

AIR-Projectとは

AIR-Projectは、プロジェクト管理をプロセスフローダイアグラムで視覚化するデスクトップアプリケーションです。タスクと成果物をノードとして配置し、それらの依存関係を矢印で結ぶことで、プロジェクトの全体像を直感的に把握できます。

プロセスフローとは

プロセスフローは、業務やプロジェクトの流れを図で表現する手法です。本アプリでは、タスク(作業)と成果物(アウトプット)をノードとして配置し、「どのタスクが完了すると、どの成果物ができるか」「どの成果物があれば、次のタスクを開始できるか」といった依存関係を矢印で可視化します。これにより、プロジェクト全体の流れが一目で把握できます。

AIR(AI Readable)とは

AIRは"AI Readable"の略で、「AIが読み取りやすい形式」を意味します。プロセスフローをMarkdown形式でエクスポートする際、Mermaid記法による図とJSON形式のデータを併記することで、人間にもAIにも理解しやすいドキュメントを生成します。これにより、GitHub上での可視化はもちろん、AIアシスタントを使った分析やプロジェクト相談にも活用できます。

主な特徴:

  • プロセスフローによる視覚的なタスク管理
  • SQLiteによる完全なオフライン動作
  • Markdown/PDFでのエクスポート機能
  • カスタマイズ可能なマスタデータ管理

アプリケーションのメイン画面
プロセスフローでタスクと成果物の関係を視覚化

技術スタック

{
  "frontend": {
    "framework": "React 18.2",
    "language": "TypeScript 5.2",
    "bundler": "Vite 5.0",
    "diagram": "@xyflow/react 12.8 / ReactFlow 11.11",
    "ui": "React Icons 5.5"
  },
  "desktop": {
    "framework": "Tauri 2.8",
    "language": "Rust (edition 2021)"
  },
  "database": {
    "engine": "SQLite 3",
    "plugin": "@tauri-apps/plugin-sql 2.3"
  }
}

Tauri 2.0での開発ポイント

1. Tauri環境の判定

Tauri v2では、環境判定の方法が変わりました。開発中に遭遇した課題として、ブラウザでの開発時とTauriアプリでの実行時で挙動を分ける必要がありました。

// Tauri環境かどうかを判定
const isTauriApp = () => {
  try {
    // Tauri v2では window.__TAURI_INTERNALS__ が存在する
    return typeof window !== 'undefined' &&
           ('__TAURI_INTERNALS__' in window || '__TAURI__' in window);
  } catch {
    return false;
  }
};

この判定により、Tauri環境ではSQLiteデータベースを、ブラウザ環境ではモックデータを使うことで、両方の環境で開発できる柔軟性を確保しています。

useEffect(() => {
  const init = async () => {
    const isTauri = isTauriApp();

    if (isTauri) {
      await initializeDatabase();
    } else {
      console.log('Browser environment, using mock data');
    }
  };
  init();
}, []);

2. SQLiteプラグインの活用

Tauri 2.0のSQLプラグインは、ローカルデータベースを簡単に扱える強力な機能です。データベースの初期化からマイグレーション、CRUD操作まで、すべてをTypeScriptから制御できます。

データベース初期化

import Database from '@tauri-apps/plugin-sql';

let db: Database | null = null;

export async function initializeDatabase() {
  try {
    // SQLiteデータベースをロード(存在しない場合は自動作成)
    db = await Database.load('sqlite:data.db');

    // スキーマバージョンをチェック
    const versionResult = await db.select<{ version: number }[]>(
      'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1'
    );

    const currentVersion = versionResult.length > 0 ? versionResult[0].version : 0;

    // マイグレーション実行
    if (currentVersion < 1) {
      await migrateToVersion1(db);
    }

    return db;
  } catch (error) {
    console.error('Database initialization failed:', error);
    throw error;
  }
}

スキーマ定義とマイグレーション

async function migrateToVersion1(db: Database) {
  // トランザクション内で全テーブルを作成
  await db.execute(`
    CREATE TABLE IF NOT EXISTS projects (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      name TEXT NOT NULL,
      description TEXT,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
    );
  `);

  await db.execute(`
    CREATE TABLE IF NOT EXISTS tasks (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      project_id INTEGER NOT NULL,
      name TEXT NOT NULL,
      status INTEGER NOT NULL,
      priority TEXT CHECK(priority IN ('low', 'medium', 'high', 'critical')),
      position_x REAL NOT NULL DEFAULT 0,
      position_y REAL NOT NULL DEFAULT 0,
      FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
    );
  `);

  // インデックス作成
  await db.execute('CREATE INDEX idx_tasks_project_id ON tasks(project_id);');

  // バージョン記録
  await db.execute('INSERT INTO schema_version (version) VALUES (1);');
}

データベースアダプター層の設計

データベース操作を抽象化したアダプター層を設けることで、コンポーネントから直接SQLを書かずに済みます。

export async function getTasks(projectId: number): Promise<Task[]> {
  const db = await getDatabase();

  const result = await db.select<TaskRow[]>(
    `SELECT * FROM tasks WHERE project_id = ? ORDER BY id`,
    [projectId]
  );

  return result.map(row => ({
    id: row.id,
    projectId: row.project_id,
    name: row.name,
    status: row.status,
    priority: row.priority as Task['priority'],
    positionX: row.position_x,
    positionY: row.position_y,
  }));
}

export async function createTask(task: Omit<Task, 'id'>): Promise<Task> {
  const db = await getDatabase();

  const result = await db.execute(
    `INSERT INTO tasks (project_id, name, status, priority, position_x, position_y)
     VALUES (?, ?, ?, ?, ?, ?)`,
    [
      task.projectId,
      task.name,
      task.status,
      task.priority,
      task.positionX,
      task.positionY
    ]
  );

  return {
    ...task,
    id: result.lastInsertId,
  };
}

3. ファイルダイアログとファイルシステム操作

Tauri 2.0では、@tauri-apps/plugin-dialog@tauri-apps/plugin-fsを使ってファイル操作を行います。

Markdownエクスポート機能の実装

import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';

async function exportToMarkdown(project: Project, tasks: Task[], deliverables: Deliverable[]) {
  // Mermaid記法でフロー図を生成
  const mermaidDiagram = generateMermaidDiagram(tasks, deliverables);

  // JSON形式で詳細データを生成
  const detailData = {
    version: '1.0',
    exportDate: new Date().toISOString(),
    project,
    tasks,
    deliverables,
    connections
  };

  // Markdown形式に整形
  const markdownContent = `# ${project.name}

## プロセスフロー図

\`\`\`mermaid
${mermaidDiagram}
\`\`\`

## プロジェクト詳細データ

\`\`\`json
${JSON.stringify(detailData, null, 2)}
\`\`\`
`;

  // ファイル保存ダイアログを表示
  const filePath = await save({
    defaultPath: `${project.name}.md`,
    filters: [{
      name: 'Markdown',
      extensions: ['md']
    }]
  });

  if (filePath) {
    await writeTextFile(filePath, markdownContent);
    console.log('Exported to:', filePath);
  }
}

このハイブリッド形式により、GitHubなどでMermaid図として可視化でき、かつJSONデータとして完全な情報を保持できます。

React Flowでのインタラクティブなダイアグラム実装

1. カスタムノードの設計

プロセスフローでは、タスクと成果物を異なる見た目のノードで表現する必要がありました。React Flowのカスタムノード機能を活用しています。

プロセスフロー画面
カスタムノードによるタスク(右)と成果物(左)の表現。ドラッグ&ドロップで自由に配置可能

import { Handle, Position, NodeProps } from 'reactflow';

interface TaskNodeData {
  label: string;
  status: TaskStatus;
  priority: Task['priority'];
  assignee?: string;
  period?: string;
}

function CustomTaskNode({ data }: NodeProps<TaskNodeData>) {
  const statusColor = getStatusColor(data.status);
  const priorityBorder = getPriorityBorder(data.priority);

  return (
    <div
      className="custom-task-node"
      style={{
        backgroundColor: statusColor,
        border: priorityBorder
      }}
    >
      <Handle type="target" position={Position.Left} />

      <div className="node-header">{data.label}</div>
      <div className="node-body">
        <div className="node-status">{data.status}</div>
        {data.assignee && <div className="node-assignee">{data.assignee}</div>}
        {data.period && <div className="node-period">{data.period}</div>}
      </div>

      <Handle type="source" position={Position.Right} />
    </div>
  );
}

// ノードタイプの登録(重要: コンポーネント外で定義)
const nodeTypes = {
  customTask: CustomTaskNode,
  customDeliverable: CustomDeliverableNode,
};

2. データベース連携とリアルタイム更新

ノードの位置変更をデータベースに永続化し、かつUI上でもスムーズに動作させる必要がありました。

function TaskFlow({ project }: TaskFlowProps) {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [nodes, setNodes, onNodesChange] = useNodesState([]);
  const [edges, setEdges, onEdgesChange] = useEdgesState([]);

  // タスク・成果物をReact Flowのノードに変換
  useEffect(() => {
    if (!project) return;

    const loadData = async () => {
      const loadedTasks = await getTasks(project.id);
      const loadedDeliverables = await getDeliverables(project.id);

      // ノード変換
      const taskNodes = loadedTasks.map(task => ({
        id: `task-${task.id}`,
        type: 'customTask',
        position: { x: task.positionX, y: task.positionY },
        data: {
          label: task.name,
          status: task.status,
          priority: task.priority,
        }
      }));

      setNodes([...taskNodes, ...deliverableNodes]);
    };

    loadData();
  }, [project]);

  // ノード移動時にデータベースを更新
  const handleNodesChange = useCallback((changes: NodeChange[]) => {
    onNodesChange(changes);

    changes.forEach(async (change) => {
      if (change.type === 'position' && change.position) {
        const [type, id] = change.id.split('-');

        if (type === 'task') {
          await updateTaskPosition(
            parseInt(id),
            change.position.x,
            change.position.y
          );
        }
      }
    });
  }, [onNodesChange]);

  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      onNodesChange={handleNodesChange}
      nodeTypes={nodeTypes}
      fitView
    >
      <Controls />
      <Background variant={BackgroundVariant.Dots} />
    </ReactFlow>
  );
}

3. 自動レイアウト機能(Dagre)

複雑なフローを自動整列させるため、Dagreライブラリを統合しています。

import dagre from 'dagre';

function autoLayout(nodes: Node[], edges: Edge[]): Node[] {
  const dagreGraph = new dagre.graphlib.Graph();
  dagreGraph.setDefaultEdgeLabel(() => ({}));
  dagreGraph.setGraph({ rankdir: 'LR' }); // 左から右へのレイアウト

  // ノードをグラフに追加
  nodes.forEach(node => {
    dagreGraph.setNode(node.id, { width: 200, height: 100 });
  });

  // エッジをグラフに追加
  edges.forEach(edge => {
    dagreGraph.setEdge(edge.source, edge.target);
  });

  // レイアウト計算
  dagre.layout(dagreGraph);

  // 計算結果をノード位置に反映
  return nodes.map(node => {
    const position = dagreGraph.node(node.id);
    return {
      ...node,
      position: {
        x: position.x - 100, // 中心座標を左上座標に変換
        y: position.y - 50
      }
    };
  });
}

マスタデータ管理とリアルタイム反映

1. マスタデータの設計思想

プロジェクト管理では、組織ごとに異なるステータスや担当者が存在します。そのため、以下のマスタテーブルを用意しました:

  • task_status_masters: タスクステータス(未着手、進行中、完了、ブロック)
  • deliverable_status_masters: 成果物ステータス(未作成、作成中、レビュー中、完成)
  • assignee_masters: 担当者(名前、メール、役割)
  • deliverable_type_masters: 成果物種類(設計書、コード、テストケース)

マスタ管理画面
共通マスタ管理画面。ステータスや担当者を自由にカスタマイズ可能

2. リアルタイム反映の実装

マスタデータを変更した際に、既存のタスク・成果物表示にも即座に反映させる仕組みを実装しました。

function App() {
  const [projectTabsKey, setProjectTabsKey] = useState(0);
  const [isMasterManagementOpen, setIsMasterManagementOpen] = useState(false);

  const handleMasterManagementClose = () => {
    setIsMasterManagementOpen(false);
    // マスタ更新後、ProjectTabsを再マウントしてマスタデータを再読み込み
    setProjectTabsKey(prev => prev + 1);
  };

  return (
    <>
      <ProjectTabs
        key={projectTabsKey} // keyを変更することで強制再マウント
        project={selectedProject}
      />

      <Modal isOpen={isMasterManagementOpen} onClose={handleMasterManagementClose}>
        <MasterManagement />
      </Modal>
    </>
  );
}

3. 削除制約の実装

使用中のマスタデータを削除できないようにする仕組みです。

async function deleteTaskStatus(statusId: number): Promise<boolean> {
  const db = await getDatabase();

  // 使用中かチェック
  const usage = await db.select<{ count: number }[]>(
    'SELECT COUNT(*) as count FROM tasks WHERE status = ?',
    [statusId]
  );

  if (usage[0].count > 0) {
    throw new Error(
      `このステータスは${usage[0].count}件のタスクで使用中のため削除できません`
    );
  }

  // 削除実行
  await db.execute('DELETE FROM task_status_masters WHERE id = ?', [statusId]);
  return true;
}

PDF/Markdownエクスポートの実装

PDF出力の技術的課題

React FlowのキャンバスをPDFに変換するため、以下の手順を実装しました:

  1. React FlowのSVGをhtml-to-imageでPNG画像化
  2. jsPDFでPDFドキュメントを作成
  3. タスク一覧・成果物一覧をjspdf-autotableで表形式で追加
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import { toPng } from 'html-to-image';

async function exportToPDF(settings: PDFExportSettings) {
  const pdf = new jsPDF('landscape', 'mm', 'a4');

  // 1. フロー図をキャプチャ
  const flowElement = document.querySelector('.react-flow') as HTMLElement;
  const imageDataUrl = await toPng(flowElement, {
    backgroundColor: '#ffffff',
    quality: 0.95
  });

  // 2. PDFに画像を追加
  pdf.addImage(imageDataUrl, 'PNG', 10, 10, 277, 180);

  // 3. タスク一覧ページを追加
  if (settings.includeTasks) {
    pdf.addPage();
    pdf.text('タスク一覧', 14, 15);

    autoTable(pdf, {
      startY: 20,
      head: [['タスク名', 'ステータス', '優先度', '担当者', '期間']],
      body: tasks.map(task => [
        task.name,
        task.statusName,
        task.priority,
        task.assigneeName || '-',
        task.startDate && task.endDate
          ? `${task.startDate}${task.endDate}`
          : '-'
      ])
    });
  }

  // 4. ファイル保存
  const filePath = await save({
    defaultPath: `${project.name}.pdf`,
    filters: [{ name: 'PDF', extensions: ['pdf'] }]
  });

  if (filePath) {
    const pdfBlob = pdf.output('blob');
    const arrayBuffer = await pdfBlob.arrayBuffer();
    await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
  }
}

開発時のTips

1. Windows環境でのRust/Cargo PATH設定

TauriコマンドをBashセッションから実行する際、PATHの設定が必要です。

export PATH="$PATH:/c/Users/{username}/.cargo/bin"
npm run tauri:dev

2. ReactFlowのノードタイプは必ずコンポーネント外で定義

これは重要なポイントです。コンポーネント内で定義すると、毎回再レンダリングで新しいオブジェクトが生成され、パフォーマンスが劣化します。

// ❌ Bad: コンポーネント内で定義
function TaskFlow() {
  const nodeTypes = {
    customTask: CustomTaskNode,
  };
  return <ReactFlow nodeTypes={nodeTypes} />;
}

// ✅ Good: コンポーネント外で定義
const nodeTypes = {
  customTask: CustomTaskNode,
};

function TaskFlow() {
  return <ReactFlow nodeTypes={nodeTypes} />;
}

3. UI実装はモーダル優先

window.alert()window.confirm()はTauriアプリでも動作しますが、デスクトップアプリとしての体験を損ねます。カスタムモーダルコンポーネントを使いましょう。

// ❌ Avoid
if (confirm('削除しますか?')) {
  await deleteTask(task.id);
}

// ✅ Better
setConfirmModalOpen(true);
// ConfirmModalからコールバックで削除実行

パフォーマンス最適化

1. データベースインデックスの活用

頻繁にクエリするカラムにはインデックスを作成しています。

CREATE INDEX idx_tasks_project_id ON tasks(project_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_deliverables_project_id ON deliverables(project_id);

2. React Flowの最適化

大規模なフロー図でもスムーズに動作させるため、以下の最適化を実施:

  • fitViewオプションで初期表示を最適化
  • onNodesChangeのコールバックをメモ化
  • 不要な再レンダリングを防ぐため、ノードデータを適切に管理

今後の展開

現在はv0.1.1ですが、今後以下の機能追加を予定しています:

  • Phase 2: ガントチャート表示、リソース管理機能
  • Phase 3: クラウド同期、チーム共有機能

特にクラウド同期については、Tauri v2の新機能である@tauri-apps/plugin-httpを活用し、REST APIとの連携を検討しています。

まとめ

Tauri 2.0は、Rustのパフォーマンスとセキュリティ、Reactの開発体験を兼ね備えた強力なフレームワークです。本プロジェクトを通じて得られた知見:

  1. SQLiteプラグインの強力さ: ローカルファーストなアプリに最適
  2. React Flowの柔軟性: カスタムノードで独自のダイアグラムを実現
  3. Tauriのファイルシステムプラグイン: セキュアなファイル操作
  4. 環境判定による開発体験の向上: ブラウザとデスクトップの両対応

Tauri 2.0は、ElectronやNW.jsと比較して以下の点で優れています:

  • バイナリサイズ: Electronの1/10以下(約5MB)
  • メモリ使用量: Chromiumを含まないため軽量
  • セキュリティ: Rustのメモリ安全性を活用
  • 起動速度: Electronより高速

デスクトップアプリ開発を検討している方は、ぜひTauriを試してみてください。

リポジトリ

本プロジェクトのソースコードは以下のリポジトリで公開しています:

https://github.com/tarotarotaros/AIR-Project

参考リンク


Discussion