0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React/TypeScript】なぜスケジュールは遅れるのか?ガントチャートに「当初計画(ベースライン)」と現在バーを上下2段で描画する設計

0
Last updated at Posted at 2026-10-20

こんにちは。プロジェクト管理SaaS「Taski(タスキ)」を個人開発しているエンジニアです。

プロジェクトを進めていると、誰しも一度はこんな状況に遭遇したことがあるはずです。

「気づいたら納期が1ヶ月延びているけれど、どのタスクが、いつ、どれだけ遅れたのか誰も把握できていない……」
「タスクの期日を後ろ倒しにし続けた結果、最初の計画がどうだったのか誰も思い出せない……」

一般的なタスク管理ツールでは、タスクが遅延したときに期日を変更すると、「当初いつまでに終わるはずだったのか」という過去の計画情報が上書きされて消滅してしまいます。

これを防ぐために、Microsoft Project などの本格的なPMツールには「ベースライン(当初計画のスナップショット)」という機能が備わっています。

プロジェクト開始時点やマイルストーン合意時のスケジュールを記録しておき、「当初の計画バー」と「現在の進行バー」を上下2段で並べて比較することで、スケジュールのズレを一目で可視化する機能です。

【一般的なツール】期日を変更すると当初計画が消えてしまう
  [ ■■■■■■■■■■■■■■■■■■■■ ]  ← いつの間にか伸びているが、当初の長さが不明

【ベースライン対応ガントチャート】上下2段でズレが一瞬で分かる
  上段(現在予定): [ ■■■■■■■■■■■■■■■■■■■■ ] ⚠️ +3日遅延
  下段(当初計画): ( ■■■■■■■■■■■■ )
                   |← 当初予定 →|← 遅延 3日間 →|

筆者が開発する Taski でもこのベースライン機能を React で自前実装しました。
本記事では、Canvas や SVG に頼らず、純粋な HTML/CSS(Flex/Grid + 絶対配置)を使って上下2段のベースライン比較バーを破綻なく描画し、軽量なスナップショットで管理する設計と実装技術を解説します。


1. UI構造:Canvasを捨て、HTML/CSSで上下2段バーを描く

ガントチャートの描画アプローチには大きく「Canvas/SVG 描画」と「HTML DOM 描画」の2通りがあります。

Canvas は大量描画に強い反面、React のコンポーネントツリーから切り離され、ホバーツールチップ、右クリックメニュー、ドラッグ&ドロップ、CSS アニメーションの自前実装が極めて苦痛になります。

Taski では 1,000 件規模のタスクでも滑らかに動くよう DOM を最適化したうえで、純粋な HTML(<div>)と Tailwind CSS の絶対配置で上下2段バーを組み立てています。

1行あたりのレイアウト構造

1つのタスク行の高さは h-12(48px固定)です。
ベースライン表示が有効な場合、この 48px の枠の中に「現在バー(上段)」と「当初計画バー(下段)」を配置します。

  0px ────────────────────────────────────────────────────────── (行の上端)
  6px   [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■] ⚠️ +3日  ← 上段: 現在バー
 30px
 32px   [■■■■■■■■■■■■■■■■■■■■]                      ← 下段: 当初計画
 42px
 48px ────────────────────────────────────────────────────────── (行の下端)
        |←────── 当初計画 ──────→|←── 遅延 3日 ──→|
  • 上段(現在バー):
    • top-1.5 h-6(上から 6px、高さ 24px)。
    • ステータスに応じたテーマカラー(青・緑・オレンジ等)で塗られ、左右端のリサイズハンドルやドラッグ移動に対応。
    • 右側に「⚠️ +3日」といった遅延バッジを表示。
  • 下段(ベースラインバー):
    • bottom-1.5 h-2.5 rounded-full(下から 6px、高さ 10px)。
    • 控えめなスレートグレー(bg-slate-400)のピル型角丸バー。
    • クリックすると当初計画の編集モーダルを開き、ホバーで当初日程のツールチップを表示。
  • ベースライン非表示時:
    • 現在バーを h-7(高さ 28px)に拡大し、上下中央にバランスよく配置。

このレイアウトにより、DOM 構造を複雑にネストさせることなく、単一の行コンテナ内で上下の重なり・余白を美しく維持できます。


2. データ構造:巨大JSONを避け、差分スナップショットにする

ベースラインを保存する際、「プロジェクト全体のチケット全件(タイトル、説明、コメント、カスタムフィールド等)を丸ごとコピーして保存」すると、あっという間にデータベースの容量を圧迫し、ネットワーク転送量(Egress)も爆発します。

ベースラインで比較したいのは「そのタスクが当初いつ開始し、いつ終わる予定だったか」という日程情報だけです。

そこで、スナップショットは「タスクIDをキーとした日付情報のマップ」のみを保持する最小限のデータ構造にしました。

// types/index.ts

/** 個別タスクの当初計画データ */
export interface GanttBaselineItem {
  start_date: string | null;  // 'YYYY-MM-DD'
  due_date: string | null;    // 'YYYY-MM-DD'
  due_time?: string | null;   // '12:00'(時間指定オプション)
}

/** プロジェクト全体のベースラインスナップショット */
export interface GanttBaseline {
  id: string;
  name: string;               // 例: "当初計画 (2026/10/15 保存)"
  saved_at: string;           // ISO 8601
  saved_by?: {
    id: string;
    name: string;
  };
  ticket_count: number;
  /** チケットID -> 当初日程 のマップ */
  tickets: Record<string, GanttBaselineItem>;
}

スナップショット生成ロジック

スナップショットの生成は、日程(開始日または期日)が設定されているアクティブなタスクのみを抽出します。

// lib/ganttBaseline.ts

export const createBaselineSnapshot = (
  tickets: Ticket[],
  user?: UserProfile | null,
  name?: string
): GanttBaseline => {
  const now = new Date();
  const ticketRecord: Record<string, GanttBaselineItem> = {};
  let count = 0;

  tickets.forEach((t) => {
    if (t.is_deleted) return;
    // 日程が1つでも入っているタスクだけを記録
    if (t.start_date || t.due_date) {
      ticketRecord[t.id] = {
        start_date: t.start_date || null,
        due_date: t.due_date || null,
        due_time: t.due_time || null,
      };
      count++;
    }
  });

  return {
    id: `baseline-${Date.now()}`,
    name: name || `当初計画 (${formatDate(now)} 保存)`,
    saved_at: now.toISOString(),
    saved_by: user ? { id: user.id, name: user.full_name || '管理者' } : undefined,
    ticket_count: count,
    tickets: ticketRecord,
  };
};

チケットが1,000件あっても、保存される JSON はわずか数十KB程度に収まります。
これを Supabase の projects テーブルにある gantt_baseline カラム(JSONB)に保存するだけで完了します。


3. 実装:上下のバーを完全同期させる座標計算

ガントチャートの描画で最も重要なのは、「現在バー」と「ベースラインバー」の X座標と幅が、1px の狂いもなく同じスケールで揃うことです。

表示スケール(日単位、午前/午後の半日単位、週単位、月単位)が切り替わっても、左右どちらかがズレてしまっては比較の意味がありません。

共通の座標変換関数を活用する

Taski では、日付文字列(YYYY-MM-DD)からタイムライン上の X座標ピクセルを返す共通関数 getXPosition(dateStr) を用意し、現在バーの計算とベースラインバーの計算の両方で全く同じ関数を通しています。

// components/GanttChart.tsx

// ベースライン(当初計画)バーの位置と幅を計算
const getBaselineBarStyle = (baselineItem?: GanttBaselineItem | null) => {
  if (!baselineItem) return null;
  
  let startStr = baselineItem.start_date;
  let endStr = baselineItem.due_date;

  // 片方しか入っていない場合は1日タスクとして補正
  if (!startStr && endStr) startStr = endStr;
  else if (startStr && !endStr) endStr = startStr;
  else if (startStr && endStr && startStr > endStr) startStr = endStr;

  // 午前締切(due_time <= '12:00')ならセル幅の半分にする
  const isMorningDue = Boolean(baselineItem.due_time && baselineItem.due_time.trim() <= '12:00');
  
  // 共通の座標変換関数を使用
  const startX = getXPosition(startStr) ?? 0;
  const endBaseX = getXPosition(endStr) ?? startX;
  const endX = endBaseX + (isMorningDue ? dayCellWidth * 0.5 : dayCellWidth);
  
  const minWidth = isMorningDue ? dayCellWidth * 0.5 : dayCellWidth;
  const width = Math.max(endX - startX, minWidth);

  return {
    startX,
    width,
    startStr,
    endStr,
    dueTime: baselineItem.due_time,
    isMorningDue,
  };
};

これで、どれだけタイムラインをズームイン/ズームアウトしても、上段バーと下段バーの基準線が寸分違わず同期します。


4. 判定ロジック:遅延・前倒し・新規タスクを正しく判定する

「計画と現在のズレ」を計算するとき、初心者が陥りがちな落とし穴があります。

それは、「完了したタスクの期日だけを見て遅延判定してしまう」というバグです。

タスクが完了(resolved/closed)している場合、比較すべきは「現在の期日」ではなく「実際に完了した日(completed_at)」でなければなりません。期日は来週になっていても、今日完了したなら前倒し完了ですし、期日は先週だったが完了したのが今日なら遅延完了です。

差分計算エンジン

// lib/ganttBaseline.ts

export const calculateBaselineDiff = (
  ticket: Ticket,
  baselineItem?: GanttBaselineItem | null
): BaselineDiffResult => {
  // 1. ベースライン保存後に追加された新規タスク
  if (!baselineItem) {
    return {
      hasBaseline: false,
      status: 'new_ticket',
      daysDiff: null,
      badgeText: '新規',
      badgeClass: 'bg-sky-50 text-sky-700 border-sky-200',
      tooltipText: 'ベースライン保存後に追加された新規タスク',
    };
  }

  // 当初計画の終了日
  const baseEnd = baselineItem.due_date || baselineItem.start_date;
  
  // 現在の終了基準日
  // 💡 完了済みなら「実績完了日」、未完了なら「設定されている期日」を見る!
  const currentEnd = ticket.completed_at 
    ? ticket.completed_at.slice(0, 10) 
    : (ticket.due_date || ticket.start_date);

  if (!baseEnd || !currentEnd) {
    return {
      hasBaseline: true,
      status: 'no_baseline',
      daysDiff: null,
      badgeText: '未定',
      badgeClass: 'bg-slate-100 text-slate-500 border-slate-200',
      tooltipText: '当初計画の日程が未設定',
    };
  }

  // 差分日数 (現在 - 当初)
  const daysDiff = getDaysDifference(baseEnd, currentEnd);

  if (daysDiff > 0) {
    // 遅延(赤バッジ)
    return {
      hasBaseline: true,
      status: 'delayed',
      daysDiff,
      badgeText: `+${daysDiff}日`,
      badgeClass: 'bg-rose-50 text-rose-700 border-rose-200 font-bold',
      tooltipText: `当初計画(${baseEnd})より ${daysDiff} 日遅延`,
    };
  } else if (daysDiff < 0) {
    // 前倒し(緑バッジ)
    const earlyDays = Math.abs(daysDiff);
    return {
      hasBaseline: true,
      status: 'early',
      daysDiff,
      badgeText: `-${earlyDays}日`,
      badgeClass: 'bg-emerald-50 text-emerald-700 border-emerald-200 font-bold',
      tooltipText: `当初計画(${baseEnd})より ${earlyDays} 日前倒し`,
    };
  } else {
    // 予定通り(グレーバッジ)
    return {
      hasBaseline: true,
      status: 'on_time',
      daysDiff: 0,
      badgeText: '予定通り',
      badgeClass: 'bg-slate-100 text-slate-600 border-slate-200',
      tooltipText: `当初計画(${baseEnd})通りに進行中/完了`,
    };
  }
};

5. 現場のリアル:運用でぶつかった「2つの泥臭い課題」

実際にドッグフーディング(自社運用)を始めてみると、教科書通りのベースラインでは対応できない泥臭い課題が2つ出てきました。

課題1: 「仕様変更で延期合意したタスク」で計画全体を再保存したくない

プロジェクト中盤で、クライアントやPOとの合意のもと「この機能はスコープ変更で期日を2週間伸ばす」と決まることがあります。

このとき、ツール全体で「ベースラインを再保存」してしまうと、他の遅れているタスクの遅延記録まで全部リセット(0日)されてしまうという致命的な問題が起きます。

解決策: 個別タスクのベースライン編集・部分同期

そこで、タスク詳細モーダルから「このタスクの当初計画だけをピンポイントで修正する」、あるいは「現在の予定をこのタスクの当初計画として上書き反映する」機能を実装しました。

// context/ProjectContext.tsx

// 特定チケットのみベースラインを更新するアクション
const updateTicketBaseline = async (ticketId: string, item: GanttBaselineItem | null) => {
  const currentBaseline = project.gantt_baseline;
  if (!currentBaseline) return;

  const nextTickets = { ...currentBaseline.tickets };
  if (item === null) {
    delete nextTickets[ticketId];
  } else {
    nextTickets[ticketId] = item;
  }

  const updatedBaseline: GanttBaseline = {
    ...currentBaseline,
    tickets: nextTickets,
    ticket_count: Object.keys(nextTickets).length,
  };

  // 楽観的UI更新を行いつつ Supabase を更新
  await updateProjectBaseline(updatedBaseline);
};

ガントチャート上の下段バーをクリックするだけで詳細モーダルが開き、合意済みのタスクだけを正規のスケジュールとして調整できるようになりました。


課題2: 後から追加されたタスクはどう見せるべきか?

プロジェクトが始まってから起票されたタスクには、当然ながら過去のベースラインが存在しません。

これを「未定」や「予定通り」として扱うと、「誰かが後から差し込んだ割り込みタスク」の存在に気づけなくなります。

解決策: 「新規(New)」ステータスの明示と統計サマリー

ベースラインのマップに存在しないタスクには、水色の「新規」バッジを付与し、ツールバーのサマリーにも「新規追加タスク: N件」としてカウントするようにしました。

【ツールバーの統計サマリー表示】
  📸 当初計画 (10/15 保存) 比較中
  [ 🔴 遅延: 3件 ]  [ 🟢 前倒し: 2件 ]  [ ⚪ 予定通り: 12件 ]  [ 🔵 新規追加: 4件 ]

これにより、「スケジュールが圧迫されている原因は、既存タスクの遅れではなく、計画後にタスクが4件も追加されたからだ」という事実がPMやチーム全員に一瞬で伝わるようになります。


6. まとめ

ガントチャートに「ベースライン(当初計画比較)」を導入したことで得られたメリットは次の通りです。

  1. 過去の計画が消えない:
    タスクの期日をどれだけ動かしても、合意時点のバーが下段に残り続けるため、振り返りや遅延理由の分析が容易になる。
  2. HTML/CSSによる軽量描画:
    Canvas のような複雑な描画ライブラリを使わず、Tailwind CSS の top-1.5 / bottom-1.5 による絶対配置だけで 60fps の滑らかな上下2段バーを実現できる。
  3. 部分更新と新規タスクの区別:
    全体再保存に頼らず個別タスクの当初計画を引き直せるようにしたことで、現場の柔軟な仕様変更に対応できる。

実際に動くデモで試せます 🚀

本記事で解説した「当初計画(ベースライン)の保存・上下2段の遅延比較UI」は、筆者が開発しているプロジェクト管理ツール Taski(タスキ) で実際に稼働しています。

会員登録不要・ワンクリック(1秒)で起動するデモ環境を用意していますので、ガントチャートのバーを動かして上下のズレや遅延バッジがどう連動するか、ぜひ触ってみてください!

👉 Taski のデモ環境を試してみる(登録不要・1秒で起動)


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?