【GAS】Googleスプレッドシートで工数フィードバック付きタスク管理表を作った

# はじめに
この春から新卒でエンジニアになりましたhinoです！
この記事が社会人初の投稿となります！！！
今回は仕事用でタスク管理表を持ちたくなったので作ってみました！！

# 個人的に求める機能
1. 時間ベースの工数実績を自動的に算出できるようにしたい
2. 進捗状況を確認できるようにしたい
3. 優先度を設定したい

## 個人的に求める機能 - 工数実績の自動算出
タスクの工数予測が正確になると、1日あたりにできることが無理なく増やすことができ、業務上における心理的安全性が高まると感じてるので、こういう機能があるとうれしいと考えています。

## 個人的に求める機能 - 進捗状況の可視化
タスクをシングルタスク的にこなすことは現実的ではないと思います。差し込みタスクによって途中で切り上げて別のタスクに取り掛かるといったことはよくあるかと思います。
そのため、今そのタスクが進行中なのか、保留中なのかといった具合にわかるとうれしいです。

## 個人的に求める機能 - 優先度設定
タスクをいくらか抱えている状態で何を優先的にやるのかを考えないといけないのは、パフォーマンスが下がってあまり好ましくないと考えています。なので、「高」「中」「低」程度でもよいのであると便利だなと考えています。

# それ、Google ToDoリストで良いのでは？
確かにGoogle ToDoリストで管理するのって操作しやすくて便利ですよね！自分も入社してしばらくは利用していました！

しかしながら、**以下の面でGoogle ToDoリストが使いにくいなと感じたため** 利用をやめました。
1. タスクの**優先度を設定しにくい**
2. タスクにかかった**時間を管理しにくいので、これからの工数を予測できない**
3. **進捗状況の可視化が難しい**

## 1. タスクの優先度を設定しにくい
Google ToDoリストで優先度を実現したいならリスト機能を使うと目的のものができるかと思います。しかしながら希望する機能的に進捗状況まで持たせる必要があります。
それをGoogle ToDoリストで行うためには
`優先度の状態数 × 進捗状況の状態数` のリストを持つ必要があるので煩わしくなります。

## 2. 工数実績の管理が難しいので、工数予測精度改善に貢献できない
これは結構問題かなと思っています。
新卒で仕事を始めたばかりなので、これから心理的安全性を保って仕事に取り組むためにも、工数予測精度が高いことは欠かせないです。
一応コメントにかかった時間を記入することでできなくはないですが、手動入力が多すぎて手間になり継続性に欠けます。

## 3. 進捗状況の可視化が難しい
これは **1. タスクの優先度を設定しにくい** と同じ理由なので割愛します。

# それじゃあGoogleスプレッドシートでどのように実現したの？？
`GAS + Googleスプレッドシート` で実現しました。
（タイトルにGASと書いているのでネタバレ感ありますが、、、笑）
より具体的には以下の図の通りで、スプレッドシートに「タスクリスト」と「工数ログ」を追加してみました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/5b2a1f06-cc44-45fa-b6dc-cdb02fbbf5d6.png)

スプレッドシートは以下の画像のようにしました。
### タスクリスト
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/d6eb103b-9b47-4668-8f9a-5ccfc59f9775.png)

### 工数ログ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/7d2b7072-2c5b-4fd1-96dc-25866746be7e.png)

ここからは処理の流れについて解説していきます。

### 進捗状況の遷移図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/bb1f85e6-5b64-4922-a2dd-c7f10de4a420.png)


1. `Not Started` になっているタスクに対して「開始」を押下すると `In Progress` に遷移
2. `In Progress` になっているタスクに対して「一時停止」を押下すると `Pending` に遷移
3. `Pending` になっているタスクに対して「再開」を押下すると `In Progress` に遷移
4. `Completed` になっているタスクに対して「終了」を押下すると `Completed` に遷移

### 工数計測の仕組み
タスクリストでタスクのIDを設定しており、工数ログでそのIDを基準にリレーションしています。タスク完了時にGAS側で工数ログのグループ集計を行い、工数実績を算出できるようにしています。


## 実装
グローバルな定数については `config.gs` に分けてみました。
```config.gs
const SHEET = SpreadsheetApp.getActiveSpreadsheet();

// シートの名前
const TASK_SHEET_NAME = 'Tasks';
const LOG_SHEET_NAME = 'TimeLog';

// NOTE: ヘッダ行を除くために追加
const ROW_OFFSET = 1;

// タスクシートのカラム定数（0-indexed）
const TASK_ID_COL = 1;
const TASK_NAME_COL = 2;
const STATUS_COL = 6; 
const ESTIMATED_TIME_COL = 7;
const REAL_TIME_COL = 8;
const TASK_DATA_RANGE = 'A2:F';

// ログシートのカラム定数（0-indexed）
const LOG_ID_COL = 0;
const LOG_TASK_NAME_COL = 1;
const START_TIME_COL = 2;
const STOP_TIME_COL = 3;
const LOG_DATA_RANGE = 'A2:D';

const STATUS = {
  IN_PROGRESS: 'In Progress',
  PENDING: 'Pending',
  COMPLETED: 'Completed',
  NOT_STARTED: 'Not Started'
};

const TASK_SHEET = SHEET.getSheetByName(TASK_SHEET_NAME);
const LOG_SHEET = SHEET.getSheetByName(LOG_SHEET_NAME);
```

### タスクの状態を管理する
```taskControl.gs
function startTaskForSelectedRow() {
  executeTaskFunction('start');
}

function pauseTaskForSelectedRow() {
  executeTaskFunction('pause');
}

function resumeTaskForSelectedRow() {
  executeTaskFunction('resume');
}

function completeTaskForSelectedRow() {
  executeTaskFunction('complete');
}
```
上の実装では「タスクリスト」上に設定しているボタンをクリックした際に発火するメソッドを設定しています。

```taskControl.gs
// 状態に合わせて実行するメソッドを調整
function executeTaskFunction(taskFunction) {
  const activeSheet = SHEET.getActiveSheet();
  const range = activeSheet.getActiveRange();
  const taskRow = range.getRow();
  const taskName = activeSheet.getRange(taskRow, TASK_NAME_COL).getValue();

  if (!taskName) {
    SpreadsheetApp.getUi().alert('セルにタスク名が設定されていません。');
    return;
  }

  switch (taskFunction) {
    case 'start':
      startTask(taskRow, taskName);
      break;
    case 'pause':
      pauseTask(taskRow, taskName);
      break;
    case 'complete':
      completeTask(taskRow, taskName);
      break;
    case 'resume':
      resumeTask(taskRow, taskName);
      break;
    default:
      SpreadsheetApp.getUi().alert('不明なタスク操作です: ' + taskFunction);
  }
}
```
`executeTaskFunction()` でボタンが押下された際にどのメソッドを実行するか、であったりタスク名が正常なのかを判定しています。

```taskControl.gs
function startTask(taskRow, taskName) {
  const currentStatus = TASK_SHEET.getRange(taskRow, STATUS_COL).getValue();
  console.log(currentStatus);
  if (currentStatus !== STATUS.NOT_STARTED) {
    SpreadsheetApp.getUi().alert('タスクはすでに開始されています。');
    return;
  }

  const taskId = TASK_SHEET.getRange(taskRow, TASK_ID_COL).getValue();

  removeExistingTaskLogs(taskId);

  TASK_SHEET.getRange(taskRow, STATUS_COL).setValue(STATUS.IN_PROGRESS);
  LOG_SHEET.appendRow([taskId, taskName, new Date(), null]);
}

function pauseTask(taskRow, taskName) {
  const currentStatus = TASK_SHEET.getRange(taskRow, STATUS_COL).getValue();
  if (currentStatus !== STATUS.IN_PROGRESS) {
    SpreadsheetApp.getUi().alert('タスクは「In Progress」ではありません。');
    return;
  }

  TASK_SHEET.getRange(taskRow, STATUS_COL).setValue(STATUS.PENDING);

  const logData = LOG_SHEET.getRange(LOG_DATA_RANGE).getValues();
  for (let i = logData.length - 1; i >= 0; i--) {
    if (logData[i][LOG_TASK_NAME_COL] === taskName && !logData[i][STOP_TIME_COL]) {
      LOG_SHEET.getRange(ROW_OFFSET + i + 1, STOP_TIME_COL + 1).setValue(new Date());
      break;
    }
  }
}

function resumeTask(taskRow, taskName) {
  const currentStatus = TASK_SHEET.getRange(taskRow, STATUS_COL).getValue();
  if (currentStatus !== STATUS.PENDING) {
    SpreadsheetApp.getUi().alert('タスクは「Pending」ではありません。');
    return;
  }

  const taskId = TASK_SHEET.getRange(taskRow, TASK_ID_COL).getValue();
  TASK_SHEET.getRange(taskRow, STATUS_COL).setValue(STATUS.IN_PROGRESS);
  LOG_SHEET.appendRow([taskId, taskName, new Date(), null]);
}

function completeTask(taskRow, taskName) {
  const currentStatus = TASK_SHEET.getRange(taskRow, STATUS_COL).getValue();
  if (currentStatus !== STATUS.IN_PROGRESS) {
    SpreadsheetApp.getUi().alert('タスクは「In Progress」ではありません。');
    return;
  }

  TASK_SHEET.getRange(taskRow, STATUS_COL).setValue(STATUS.COMPLETED);

  const logData = LOG_SHEET.getRange(LOG_DATA_RANGE).getValues();
  let totalTime = 0;

  for (let i = 0; i < logData.length; i++) {
    if (logData[i][LOG_TASK_NAME_COL] === taskName) {
      if (!logData[i][STOP_TIME_COL]) {
        const stopTime = new Date();
        LOG_SHEET.getRange(i + ROW_OFFSET, STOP_TIME_COL + 1).setValue(stopTime);
        logData[i][STOP_TIME_COL] = stopTime;
      }
      totalTime += (logData[i][STOP_TIME_COL] - logData[i][START_TIME_COL]);
    }
  }

  TASK_SHEET.getRange(taskRow, REAL_TIME_COL).setValue(Math.ceil(totalTime / (1000 * 60)));
}
```
`startTask()`, `pauseTask()`, `resumeTask()`, `completeTask()` では状態遷移図に対応した処理を行っています。

# おまけ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1611491/5fb0b50f-97ac-407a-bf8a-26446d72b490.png)
社内のSlackで新卒同期向けにこのスプレッドシートをテンプレ化してみたら結構需要ありました...
こういう些細な事でも凄いとか言ってもらえるのって結構うれしいですね～

# まとめ
今回はGoogleスプレッドシート + GAS で工数管理機能を有したスケジュール管理表を作成しました！
ちょっと不便だなって思う事に対して、GASを使って改善ができるのはGoogle系サービスの強みなのかなと改めて思ったのと同時に、このようなことをやるのが楽しいな～って思いました！