エムオーテックス株式会社が運営するテックブログです。

Backlog で複数プロジェクトの工数を見える化!するツールを開発しました

Backlog で複数プロジェクトの工数を見える化!するツールを開発しました

はじめに

こんにちは、サービス戦略課の森田です。
サービス戦略課では LANSCOPE エンドポイントマネージャー クラウド版の技術的負債解消やフローの自動化など開発者の生産性向上のためのサービス改善に日々取り組んでいます。

今回は、Backlog において複数プロジェクトにまたがる課題の予定時間を見える化する方法についてご紹介したいと思います。

Backlog の運用

エムオーテックスでは、プロジェクト管理に Backlog を活用しています。

開発チームは複数存在し、それぞれの開発チームに対して Backlog のプロジェクトが作成され、運用されています。
案件ごとにプロジェクトを作成するのではなく、開発チームA は Backlog のプロジェクトA を継続して使用しています。
各チームは、自身のプロジェクトでカテゴリに案件名を追加し、課題にカテゴリを設定する運用を行っています。

開発部隊
┣開発チームA
┃┗ Backlog のプロジェクトA を使い続けている
┃  ┗課題: 〇〇 する (カテゴリ = 案件1) 
┣開発チームB
┃┗ Backlog のプロジェクトB を使い続けている
┃  ┗課題: △△ する (カテゴリ = 案件2) 
┗開発チームC
 ┗ Backlog のプロジェクトC を使い続けている
   ┗課題: XX する (カテゴリ = 案件3) 

※各チームは案件名のカテゴリを追加しており、課題にそのカテゴリを設定しています。

テストチームやフロントエンドチームなどの専門部隊は、案件ごとに必要に応じて各開発チームと協力して作業を行います。
そのため、各開発チームの案件に随時参画する必要があります。
Backlog 上では、プロジェクトA やプロジェクトB など複数のプロジェクトで課題が割り振られることがあります。

課題

「プロダクトマネージャーや組織の責任者が、開発部隊全員のタスク状況を可視化して分析したい」という要望があるとお聞きしました。
特に、専門部隊は各課の案件に割り当てられるため動きが見えづらいとのことでした。

具体的な要望は以下のようなものです。

  • 負荷が高いメンバーを特定したい。
  • XX さんが次の案件に取り組める時期を知りたい。
  • 現在の作業が遅れた場合、どの次の作業に影響が出るかを把握したい。
  • 割り込み業務が発生した際、対応できる人を特定したい。
  • 各メンバーのタスク状況を週次でチェックしたい。

Backlog には、ガントチャートを利用できたり、複数プロジェクトの課題を横断的に検索できるなど、便利な機能がいくつかあります。
しかし、複数プロジェクトにまたがって各人の課題担当状況を可視化したり、予定時間の合計を週次で確認することはできません。

そこで、何か良い方法がないかと検討を進めました。

成果物

システム化も検討しましたが、まずは軽量で最小限の実現を目指す方向性で検討を進めました。

Backlog は API が提供されているため、API を経由して複数プロジェクトの課題一覧を取得し、グラフなどで予定時間を可視化することを検討しました。
可視化方法としては、ピボットテーブルを用いてある程度自由に分析できることで、要件を満たすことができると考えました。

実際に完成したものは以下の通りです。

ピボットテーブルを閉じた状態

ピボットテーブルを開いた状態

操作

  • 各行列の「>」ボタンをクリックすることで、ドリルダウンが可能です。
  • 画面上部の「+」「-」ボタンを使って、一括で展開できます。
    「Σ」ボタンは、行列ごとの合計を表示するかどうかを切り替えます。
    「Export」ボタンを押すと、エクセルに出力できます。
  • 行列の緑色の四角にある列名をドラッグアンドドロップすることで、ドリルダウンの解除や追加ができます。
    また、緑色の四角にマウスカーソルを合わせると、フィルタボタンが表示されます。そこからフィルタリングができます。

動作

  • 初期状態では、チーム単位 x 週ごとの予定時間の合計が表示されます。
    • 行: チーム > メンバー > 案件 > 課題の単位でドリルダウンができます。
    • 列: 週 > 日の単位でドリルダウンができます。
  • 上記サンプルデータでは、ほとんどの課題に予定時間が入力されていないため、0h または空白で表示されます。
    • 0h もしくは時間が表示されている:
      課題の開始 - 終了の日数で予定時間を割って時間を表示しています。
      つまり、どの日に作業するかがわからないため、均等に時間を割り振っています。
      例: 開始 3/1 - 期限 3/5 (予定 5 h) の場合は、3/1 から 3/5 まで各日 1h と表示します。
      例: 予定時間が入っていない場合は 0h と表示します。
    • 空白: 課題の開始 - 終了の期間外です。

利用した技術

簡単に実装できる ピボットテーブルを探していたところ、以下のライブラリを見つけ、利用させていただきました。

以下の機能が提供されており、ピボットテーブルに必要な操作は一通り網羅されていると思います。

  • フィールドをドラッグアンドドロップで移動
  • フィールドをクリックしてソート
  • 値のフィルター
  • フィールドのドリルダウン
  • 複数のデータフィールドをサポート
  • データセルの集計および書式設定機能
  • 総計と小計の表示・非表示
  • 小計の展開/折りたたみ
  • 固定ヘッダー
  • Excel へのエクスポート

ピボットテーブルのデータソースは、行の配列を渡すだけで構いません。

   function getData() {
        return [
['Contoso Florida', 'Proseware LCD17W E202 Black', 'Proseware, Inc.', 'Economy', 'Monitors', 4, 509.55],
['Contoso New Jersey', 'Adventure Works CRT15 E101 Black', 'Adventure Works', 'Economy', 'Monitors', 4, 351],
...

このように、簡単に実装できるピボットテーブルを求めていた際に、上記のライブラリを見つけて利用しました。

実装

公式リポジトリからクローンします: Orb, pivot table javascript library
ディレクトリ構成はそのままにし、demo ディレクトリ内の JavaScript と HTML を修正します。

解説はインラインで行います。

demo/demo.data.js

1. Backlog から課題一覧を取得する。

//demo.data.js

async function getIssues() {
  const apiKey = "*****";
  const baseUrl = "https://*****.backlog.com{}apiKey={}";
  const issuesUrl = "/api/v2/issues?";
  const issuesCountUrl = "/api/v2/issues/count?";

  // 要件に応じてパラメーターを追加してください。
  let params = "&count=100";  // 1 度に取得できる課題の最大数は 100 件までです。
  params += "&projectId[]=00000";  // 取得したいプロジェクトの数だけ設定します。
  params += "&projectId[]=11111";
  params += "&projectId[]=22222";
  params += "&projectId[]=33333";
  params += "&startDateSince=2024-03-01";
  params += "&startDateUntil=2024-03-31";
  params += "&dueDateSince=2024-03-01";
  params += "&dueDateUntil=2024-03-31";

  // 課題は 100 件ずつしか取得できないため、まず取得候補となる課題の合計数を取得します。
  let url = baseUrl.replace("{}", issuesCountUrl).replace("{}", apiKey);
  const countRes = await fetch(url + params);
  const countJson = await countRes.json();
  const issuesCount = countJson.count; // ex. 500 

  // HTML にインジケーターを表示するための描画処理。
  var indicator = document.getElementById("indicator");
  indicator.innerText = "0 / " + issuesCount;

  let allIssues = [];

  let i = 0;
  while (i < issuesCount) {
    indicator.innerText = i + " / " + issuesCount;

    // 取得候補の課題数になるまで 100 件ずつオフセットを指定して繰り返し取得します。
    tempParams = params + "&offset=" + i;
    url = baseUrl.replace("{}", issuesUrl).replace("{}", apiKey) + tempParams;
    const res = await fetch(url);
    const resJson = await res.json();
    allIssues = allIssues.concat(resJson);
    i += 100;
  }
  return allIssues;
}

2. 取得した課題一覧をピボットテーブルのデータソースに変換する。

//demo.data.js

async function getBacklogIssues() {
  return getIssues()
    .then((issues) => {
      // チーム名と所属メンバーのマッピングを定義します。
      const teamUserMapping = [
        {
          チームA: [
            "山田太郎",
            "山田次郎",
          ],
        },
        {
          チームB: [
            "佐藤花子",
            "佐藤咲子",
          ],
        },
        {
          チームC: [
            "藤原一郎",
            "藤原二郎",
          ],
        },
      ];
      // 課題の担当者名から、上記の所属チーム名を取得するための関数です。
      function getUserTeam(userName) {
        for (const mapping of teamUserMapping) {
          for (const team in mapping) {
            if (mapping[team].includes(userName)) {
              return team;
            }
          }
        }
        return "未割り当て";
      }
      // 課題名が長すぎるとピボットテーブル上で見づらいため指定文字数で切り捨てるための関数です。
      function truncateString(str, maxLength) {
        if (str.length > maxLength) {
          return str.substring(0, maxLength - 3) + "...";
        } else {
          return str;
        }
      }
      // 取得した課題一覧をピボットテーブルのデータソースに変換します。
      function convertData(data) {
        const result = [];
        data.forEach((item) => {
          // 担当者がいない課題は [担当なし] と表示します。
          const user =
            item.assignee && item.assignee.nulabAccount
              ? item.assignee.nulabAccount.name
              : "担当なし";
          // 担当者名から所属チーム名を取得します。
          const team = getUserTeam(user);
          // ピボットテーブル上で [プロジェクト名] フィールドを表示しますが、Backlog 上のカテゴリ名を使用します。
          const projectTitle = truncateString(item.category[0].name, 20);
          // 表示上はプロジェクト名を切り詰めるため、ツールチップでプロジェクト名全文が確認できるようにします。
          const project =
            '<span title="' +
            item.category[0].name +
            '">' +
            projectTitle +
            "</span>";
          // 課題もプロジェクト名と同様に切り詰めます。
          const issueTitle = truncateString(item.summary, 20);
          const issueTooltip = item.issueKey + " " + item.summary;
          // a タグで課題へのリンクを設定しています。
          const issue =
            '<span><a title="' +
            issueTooltip +
            '" href="https://*****.backlog.com/view/' +
            item.issueKey +
            '" target="_blank">' +
            issueTitle +
            "</a></span>";

          // 課題の予定時間を、開始日と期限日の期間で均等に平準化します。次の While 文にて期間の日数分でレコードを分割します。
          // 例: 開始日 3/1, 期限日 3/5, 予定 5h の場合、「3/1 1h」「3/2 1h」「3/3 1h」「3/4 1h」「3/5 1h」の 5 レコードに分割します。
          const startDate = new Date(item.startDate);
          const dueDate = new Date(item.dueDate);
          const diffTime = Math.abs(dueDate - startDate);
          const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
          const estimatedHoursPerDay =
            item.estimatedHours / diffDays || 0;

          let currentDate = new Date(startDate);
          while (currentDate <= dueDate) {
            // 要件として課題の予定時間を週単位で分析する必要があるため、課題がどの週に属するかを求めます。

            // 課題が属する週の開始日を求めます。日曜日始まりとします。
            const sunday = new Date(currentDate);
            sunday.setDate(currentDate.getDate() - currentDate.getDay());

            // 課題が属する週の終了日を求めます。
            const saturday = new Date(currentDate);
            saturday.setDate(currentDate.getDate() - currentDate.getDay() + 6);

            // 課題が属する週を [週の開始日 ~ 週の終了日] 形式で文字列化します。
            const week = `${sunday.toISOString().slice(0, 10)} ~ ${saturday
              .toISOString()
              .slice(0, 10)}`;
            const date = currentDate.toISOString().slice(0, 10);

            // データソースに追加します。
            result.push([
              team, // チームA
              user,  // 山田太郎
              project,  // 案件1
              issue,  // 〇〇 を実装する
              week,  // 2024-03-03 ~ 2024-03-09
              date,  // 2024-03-03
              estimatedHoursPerDay,  // 1h
            ]);

            currentDate.setDate(currentDate.getDate() + 1);
          }
        });
        return result;
      }
      return convertData(issues);
    })
    .catch((error) => {
      console.error("There was a problem with the fetch operation:", error);
    });
}

demo/demo.html

ほぼ公式リポジトリのデモコードを流用しています。

// demo.html

<!DOCTYPE html>
<html>

<head>
    <title>Pivot Table</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <link rel="stylesheet" type="text/css" href="../deps/bootstrap-3.3.1/css/bootstrap.css" />
    <link rel="stylesheet" type="text/css" href="../deps/bootstrap-3.3.1/css/bootstrap-theme.css" />

    <!-- RELEASE --
    <link rel="stylesheet" type="text/css" href="../dist/orb.min.css" />
    <script type="text/javascript" src="../deps/react-0.12.2.min.js"></script>
    <script type="text/javascript" src="../dist/orb.min.js"></script>
    !-- ******* -->

    <!-- DEBUG -->
    <link rel="stylesheet" type="text/css" href="../dist/orb.css" />
    <script type="text/javascript" src="../deps/react-0.12.2.js"></script>
    <script type="text/javascript" src="../dist/orb.js"></script>
    <!-- ***** -->

    <!-- このあとに記載している custom.css を取り込みます -->
    <link rel="stylesheet" type="text/css" href="custom.css" />
    <script type="text/javascript" src="demo.data.js"></script>

    <style type="text/css">
        body {
            font-size: 1.2em;
        }
    </style>
</head>

<body>
    <div class="loading">
            <span class="circle"></span>
            <span class="indicator" id="indicator">データ取得中...</span>
    </div>
    <button style="margin: 5px;" type="button" class="btn btn-success" onclick="refreshData()">リロードする</button>
    <button style="margin: 5px;" type="button" class="btn btn-success"onclick="changeTheme()">テーマを変更する</button>
    <a download="orbpivotgrid.xls" href="#" onclick="return exportToExcel(this);">エクセルに出力する</a>

    <div id="rr" style="padding: 7px;"></div>
    <div id="export" style="padding: 7px;"></div>

    <script type="text/javascript">
        // demo.data.js に定義したデータ取得関数を呼び出してピボットテーブルにデータソースを設定します。
        const getDemoData = (callback) => callback().then((data) => {
            const loading = document.querySelector( '.loading' );
            loading.classList.remove( 'hide' );
            pgridwidget.refreshData(data)
            loading.classList.add( 'hide' );
        });
        getDemoData(getBacklogIssues);

        // リロードボタンをクリックしたときに、データソースを再取得します。
        function refreshData() {
            const loading = document.querySelector( '.loading' );
            loading.classList.remove( 'hide' );
            pgridwidget.refreshData(getDemoData(getBacklogIssues));
        }

        // ピボットテーブルのテーマ色が変更できるため、お遊びでランダムに変更する処理です。
        function changeTheme() {
            var colors = ["red", "green", "blue", "orange", "flower", "gray", "white", "black", "bootstrap"];
            var randomColor = colors[Math.floor(Math.random() * colors.length)];
            pgridwidget.changeTheme(randomColor);
        }

        // データソースをエクセルに出力する処理です。
        function exportToExcel(anchor) {
            anchor.href = orb.export(pgridwidget);
            return true;
        }

        // ライブラリの設定です。
        // 以下の公式ドキュメントを参照してください。
        // https://nnajm.github.io/orb/doc-pgridwidget.html#options
        var config = {
            dataSource: [],
            canMoveFields: true,  // フィールドをドラッグドロップできるか
            dataHeadersLocation: 'columns',
            width: window.innerWidth - 60,
            height: window.innerHeight - 70,
            theme: 'green',
            toolbar: {
                visible: true
            },
            grandTotal: { // 総計を表示するか
                rowsvisible: false,
                columnsvisible: false
            },
            subTotal: {  // 小計を表示するか
                visible: false,
                collapsed: false,
                collapsible: false
            },
            rowSettings: {  // 行に対するグローバル設定です。
                subTotal: {
                    visible: true,
                    collapsed: true,
                    collapsible: true
                }
            },
            columnSettings: {  // 列に対するグローバル設定です。
                subTotal: {
                    visible: true,
                    collapsed: true,
                    collapsible: true
                }
            },
            fields: [  // フィールドごとの振る舞いを設定します。上記のグローバル設定を上書きすることができます。
                {
                    name: '6',
                    caption: '予定時間',
                    dataSettings: {  // データフィールドとして扱う場合に設定します。
                        aggregateFunc: 'sum',  // このフィールドではデータを合計します。
                        aggregateFuncName: '合計',
                        formatFunc: function (value) { // 値をフォーマットできます。ここでは単位 (h) を追加します。
                            return value ? Number(value).toFixed(1) + ' h' : value === null ? '' : '0 h';
                        }
                    }
                },
                {
                    name: '0',
                    caption: 'チーム',
                    sort: {
                        order: 'asc'
                    },
                },
                {
                    name: '1',
                    caption: 'メンバー',
                },
                {
                    name: '2',
                    caption: 'プロジェクト',
                },
                {
                    name: '3',
                    caption: '課題'
                },
                {
                    name: '4',
                    caption: '週',
                    sort: {
                        order: 'asc'
                    },
                },
                {
                    name: '5',
                    caption: '日',
                    sort: {
                        order: 'asc'
                    },
                },
            ],
            rows: ['チーム', 'メンバー', 'プロジェクト', '課題'],  // 初期表示の行を設定します。
            columns: ['週', '日'],  // 初期表示の列を設定します。
            data: ['予定時間'],    // 初期表示のデータフィールドを設定します。
        };

        var elem = document.getElementById('rr')

        var pgridwidget = new orb.pgridwidget(config);
        pgridwidget.render(elem);

    </script>
</body>

</html>

demo/custom.css

フィールド名が日本語の場合、文字列が縦に折り曲がってしまう現象が起こります。それを解決するために、CSS で適切な調整を行います。

// custom.css

.fld-btn {
    white-space: nowrap;
  }
  
  .orb .inner-table.upper-buttons{
    table-layout: fixed;
  }
  
  .orb .av-flds{
    overflow-x: auto;
  }
  
  ::-webkit-scrollbar {
    width: 10px;
    height: 10px;
  }
  
  ::-webkit-scrollbar-thumb {
    border-radius: 3px;
    background:#ebf7e7;
    border:1px solid #9fda8b;
  }
  
  ::-webkit-scrollbar-thumb:active {
    border-radius: 3px;
    background:#9fda8b;
  }

  .loading {
    position: fixed;
    z-index: 1000;
    width: 100%;
    height: 100vh;
    margin: 0;
    padding: 0;
    background: #fdfdfd;
  }
  .indicator {
    position: fixed;
    z-index: 1000;
    width: 100%;
    height: 100vh;
    margin: 0;
    padding: 0;
  }
  .loading.hide {
    opacity: 0;
    pointer-events: none;
    transition: opacity 500ms;
  }
  .loading .circle {
    display: block;
    position: relative;
    top: calc( 50% - 20px );
    width: 40px;
    height: 40px;
    margin: 0 auto;
    border: 8px solid #e0e0e0;
    border-top: 7px solid #97b699;
    border-radius: 50px;
    animation: loading 700ms linear 0ms infinite normal both;
  }
  @keyframes loading {
    0% { transform: rotate( 0deg ); }
    100% { transform: rotate( 360deg ); }
  }
  .indicator {
    display: block;
    position: relative;
    top: calc( 50% - 20px );
    text-align: center;
    margin: 0 auto;
  }

おわりに

今回は、Backlog で複数のプロジェクトにまたがる課題の予定時間を可視化するツールを作成しました。

ツールの作成をリクエストしてくださった方からは、「やりたかったことがほぼ実現されていて感動しています!」とのお声をいただきました。

まだまだ改善できる点はあると思いますが、実は現時点では運用するかどうかは確定していません。
しかし、もし運用が決まれば、さらにブラッシュアップしていけると考えています。

ここまでお読みいただき、誠にありがとうございます。
本内容がお役に立てれば幸いです。