Wrikeとスプシでバーンダウンチャートを自作しよう

こんにちは、[グレンジ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/grenge)の3日目担当のmesshiです。
今年もこの時期がやってきましたね。引き続きニッチな記事を書こうかなと思います。


# 背景
弊社では今期、[株式会社アトラクタ様](https://www.attractor.co.jp/)のお力を借りて、スクラムをゲーム開発に取り入れてみました。
スクラムを導入する際の工夫や、実際にやってみた所感などは、また別のところで触れたいと思います。
そんなこんなで、私はスクラムマスターとしてその開発に携わることになりました。

さて、スクラムイベントの1つにデイリースクラムというものがあります。
デイリースクラムは、スプリントゴールに対する検査と適応を行うことが目的ですが、そのための手法の1つにバーンダウンチャートがよく利用されていることを知りました。

そこで、タスク管理ツールとして使用していたWrikeでそのような機能がないかを調べましたが
- ツールとしてのそもそも機能がない
- APIを公開しているので、そのAPI経由で自作するしかない
    - AppScrpitで実現している[記事](https://qiita.com/shirahama_manabu/items/6152bb8213c4adfe2d6d)がある (ただしコードはない)

という状態だったので、結局自作することになりました。

今回はそういった方向けに、コードも交えた解説記事を書ければと思います。


# インプット
まずデータとして、事前にインプットしておくものは下記になります。

- スプリントの日程データ
- スプリントを管理するWrikeのPermaLinkURL

つまり、スプレッドシートで次のような入力フォームを用意します。
<img width="350" alt="input_sheet.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35666/b1d8df19-674f-f1d6-976b-53075ee6c71c.png">



ちなみにPermaLinkURLは以下のように、特定スプリントのタスクをまとめる親プロジェクトのURLを貼り付けます。![wrike.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35666/462b5592-37e7-44b0-62b3-c8f7f30466ed.png)

また、日程データは祝日などのイレギュラーケースに柔軟に対応できるようにするため、手動入力にしています。
基本的に各スプリントの最初に設定するだけなので、設定コストも大して掛かりませんでした。


# アウトプット
次のようなテーブルに対して、AppScriptから自動入力するようにします。 
自動入力対象は、青いタイトルセル部分です。
![output_sheet.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35666/0a2f3ff2-5298-e56c-7e0b-4aeec23c7d0e.png)

これらをAppScriptから入力するには、Wrikeのデータから以下を計算します。

- スプリントの総日数
- タスクの総工数
- タスクの完了工数

この表をグラフに反映させると、次のような折れ線グラフが作成できます。
<img width="527" alt="burndown.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35666/50028749-f5b2-f0ea-360a-e338e44f9fb4.png">

では実際に作っていきましょう

# 0. 事前準備
Wrikeのデータを取得できるようにするには、APIの設定が必要です。

まず、Wrikeのプロフィールボタンから「アプリ & 連携」を押下します。
<img width="500" alt="wrike_api_001.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35666/4b9d23ef-fd7b-f894-09bf-913d66ebcd04.png">

次に「API」を押下します。
<img width="500" alt="wrike_api_002.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35666/7162c135-c91a-c5ec-6d5a-a42d95d78dce.png">

適当なアプリ名を入れて、「新規作成」を押下しましょう。
<img width="650" alt="wrike_api_003.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35666/beca1edc-d452-1a3f-ecd8-936cca476787.png">

永久アクセス・トークンから、「トークンを作成」を押下します。
<img width="500" alt="wrike_api_004.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35666/b67eef95-b96e-13ba-9582-6b031ba0a8a5.png">

トークンは以降表示されないので、ローカルにコピーしておいてください。
これで事前準備は完了です。

# 1. セル情報を定義する
以降は**Google App Scriptでの作業**になります。
適当なスプレッドシートを用意し、Google App Scirptを書いていきましょう。

まずは、シート内のどのセルから情報を取得し、どのセルに計算結果を出力するかの情報を定義する必要があります。
この定義情報はシートを編集した際に変わってしまう可能性もあるため、コードのあちらこちらで定義されるとメンテナンスコストが上がってしまいます。
そのためファイルの冒頭で定義しておくと良いでしょう。

ここではセルの配置情報をまとめたCellDefineクラス、アウトプット情報をまとめたBurndownInfoを定義します。
```calculate.gs
/******* 定義クラス *******/
/* 工数情報 */
class BurndownInfo {
  //コンストラクタを定義
  constructor(totalTime, completedTime) {
      this.totalTime = totalTime
      this.completedTime = completedTime
  }
}

/* セル情報 */
class CellDefine {
  //コンストラクタを定義
  constructor(permalink, scheduleStart, leftTimeStart, totalTimeStart, totalManHour, totalDays, doneTotalTimeCell) {
      this.permalink = permalink
      this.scheduleStart = scheduleStart
      this.leftTimeStart = leftTimeStart
      this.totalTimeStart = totalTimeStart
      this.totalManHour = totalManHour
      this.totalDyas = totalDays
      this.doneTotalTimeCell = doneTotalTimeCell
  }
}
/******* 定義クラス *******/
```

次に、CellDefineクラスに定義情報を流し込みます
```calculate.gs
/******* グローバル変数 *******/
// 読み取り
const permalinkCell = 'C2'        // スプリントパーマリンク (Wrike)
const scheduleStartCell = 'C8'    // スケジュール開始セル

// 書き込み
const leftTimeStartCell = 'L8'    // 残工数の開始セル
const totalTimeStartCell = 'J8'   // 総工数の開始セル (その日時点)
const totalManHourCell = 'I3'     // 総工数
const totalDyasCell = 'I4'        // 総日数
const doneTotalTimeCell = 'L3'    // 完了総工数

// セルの情報を定義する
const cellDefine = new CellDefine(permalinkCell, scheduleStartCell, leftTimeStartCell, totalTimeStartCell,  totalManHourCell, totalDyasCell, doneTotalTimeCell)
/******* グローバル変数 *******/
```

これでセル情報を各関数から容易にアクセスできるようになりました。

# 2. アルファベットを数値に変換する関数を定義
GASでは特定のセルにアクセスする際、列番号と行番号を入力します。
たとえば、A3セルの場合は、「列 = 1, 行 = 3」となります。
そのためアルファベットを数値に変換する関数を定義しておくと良いでしょう。

```calculate.gs
// アルファベットを数値に変換する (1始まり)
function ConvertToNumber_(strCol) {  
  var iNum = 0
  var temp = 0
  
  strCol = strCol.toUpperCase()
  for (i = strCol.length - 1; i >= 0; i--) {
    temp = strCol.charCodeAt(i) - 65 // 現在の文字番号;
    if(i != strCol.length - 1) {
      temp = (temp + 1) * Math.pow(26,(i + 1))
    }
    iNum = iNum + temp
  }
  return iNum + 1
}
```

# 3. スプレッドシートから各種情報を取得する
スプリントの日時情報を取得する関数を定義します。
```calculate.gs
// スプリントの日程を取得する
function GetSprintDay_(sheet, rowOffset) {
  const cellCol = ConvertToNumber_(cellDefine.scheduleStart.substring(0, 1))
  let rowIndex = Number(cellDefine.scheduleStart.substring(1)) + rowOffset
  let dayString = sheet.getRange(rowIndex, cellCol).getValue()
  return dayString
}
```
引数のsheetには、入力先のシートを渡せば良いでしょう。
たとえば、次のように記載すれば、現在アクティブなシートを取得できます。
`  let sheet = SpreadsheetApp.getActiveSheet()`

またこの関数を利用して、日程データから総日数を計算する関数を定義することもできます。

```calculate.gs
// スプリントの合計日数を取得する
function GetTotalDays_(sheet) {
  let rowOffset = 0
  let dayCount = 0
  while (GetSprintDay_(sheet, rowOffset) != '') {
    dayCount++
    rowOffset++
  }

  // Day0から計算しているので、-1が必要
  return dayCount - 1
}
```
やっていることはシンプルに、日程データに関して1行ずつパースし、セルが空になるまでの数をインクリメントして算出しています。


# 4. Wrike APIをリクエストする関数を定義
WrikeのAPIドキュメントに定義されているAPIに、リクエストを行う関数を定義します。
ここでは特定のAPIパスを受け取ったと仮定した汎用的な関数を定義します。


```calculate.gs
// APIのリクエストを送信する
function SendRequest_(urlPath, method, payload) {
  let baseUrl = 'https://www.wrike.com/api/v4' + urlPath
  let options = CreateOptions_(method, payload)
  let response = UrlFetchApp.fetch(baseUrl, options)
  return response
}
```

CreateOptions関数は以下のように記載します。
`accessToken`には、[事前準備](#0-事前準備) にて作成したアクセストークンを貼り付けてください。

```calculate.gs
// APIリクエスト時のオプションを作成する
function CreateOptions_(method, payload) {
  const accessToken  = '一番初めに作成したアクセストークンを入力する';

  let headers =
  {
    'Authorization': 'Bearer ' + accessToken 
  };

  let options =
  {
    'method': method,
    'headers': headers,
    'payload': payload,
  };

  return options
}
```

# 5. 特定のWrike APIをリクエストする関数を定義
APIドキュメントに記載されている「タスク一覧」を取得するための関数を定義します。
該当するドキュメントは[こちら](https://developers.wrike.com/api/v4/tasks/)です。

```calculate.gs
// Permalinkから特定フォルダ以下のタスクリストを取得する
function GetFolderTasks_(permalink) {
  // 特定フォルダ以下のタスクリストを取得する
  let folderId = GetFolderId_(permalink)
  let urlPath = '/folders/' + folderId + '/tasks'
  let response = SendRequest_(urlPath, 'GET')
  let jsonData = JSON.parse(response.getContentText())
  let dataArray = jsonData['data']
  return dataArray
}
```

タスク一覧を取得するにはフォルダのIDが必要になります。
次のように、フォルダのIDを取得する関数も定義しておきましょう。
```calculate.gs
// PermalinkからフォルダのIDを取得する
function GetFolderId_(permalink) {
  let urlPath = '/folders/?permalink=' + permalink
  let response = SendRequest_(urlPath, 'GET')

  let jsonData = JSON.parse(response.getContentText())
  let folderId = jsonData['data'][0].id
  return folderId
}
```

これでタスクの一覧が取得できました。
あとは、このタスクを1つ1つ精査していくだけです。

# 6. 指定した日付までに完了しているタスク情報を計算する
「タスクリスト」と「日付」を受け取り、その日付までに完了しているタスクを計算してみます。

関数は以下のようになります。

```calculate.gs
// 指定した日付までに完了したタスクの工数を取得する
function GetCompletedTimeByDay_(taskList, targetDay) {
  // 総工数と完了工数を算出する
  let totalTime = 0
  let completedTime = 0
  
  for (let i = 0; i < taskList.length; i++) {
    let task = taskList[i]
    
    let taskId = task['id']
    let detailTask = GetTask_(taskId)

    // キャンセルしたタスクは考慮しない
    if (detailTask['status'] == 'Cancelled') {
      continue
    }

    let estimateTime = GetCustomDataValue_(detailTask, 'IEAB3RLHJUAETX5F')
    if (estimateTime < 0) {
      continue
    }

    let estimateTimeSplits = estimateTime.split(':')
    let hour = Number(estimateTimeSplits[0])
    let minute = Number(estimateTimeSplits[1])
    let man_hour = hour + minute / 60.0

    totalTime += man_hour
    // 完了済みのタスク計算
    if (detailTask['status'] != 'Active') {
      let completeDate = GetDateByWrikeFormat_(detailTask['completedDate'])
      if (completeDate < targetDay) {
        completedTime += man_hour
      }
    }
  }

  Logger.log(totalTime + " " + completedTime)
  return new BurndownInfo(totalTime, completedTime)
}
```

ここでのポイントは2つあります。

## ポイント1： 詳細なタスク情報を取得する
taskListで取得できるタスクは情報が少なく、より詳細な情報を取得するためには別のAPIを叩く必要があります。

ちなみに、taskListから取得できるタスク情報は以下のような内容です。
一応、タスクのステータスや完了時刻は取れるので、必要最低限の情報は取れるので、これで計算することも可能です。
```
{
    priority=8ac0c0007ffffffffffbf000,
    updatedDate=2023-11-22T10:15:30Z,
    dates={type=Backlog},
    createdDate=2023-11-22T01:40:40Z,
    accountId=XXXXXXXX,
    scope=WsTask,
    importance=Normal,
    id=IEAB3RLHKRFM5MH3,
    status=Completed,
    customStatusId=IEAB3RLHJMD3AYDL,
    completedDate=2023-11-22T10:15:30Z,
    title=Wrikeのタイトル名がここに入ります,
    permalink=https://www.wrike.com/open.htm?id=1111111111
}
```

次のような関数を定義し、より詳細なタスク情報を取得できるようにします。
```
// タスクIDからタスクを取得する
function GetTask_(taskId) {
  let urlPath = '/tasks/' + taskId
  let response = SendRequest_(urlPath, 'GET')

  let jsonData = JSON.parse(response.getContentText())
  return jsonData['data'][0]
}
```

こちらの関数でタスク情報を取得すると以下のような内容が取得できます。
```
{
    subTaskIds=[],
    customFields=[{value=01:00, id=IEAB3RLHJUAETX5F}],
    title=Wrikeのタイトル名がここに入ります,
    dates={type=Backlog},
    followerIds=[KUAQECPE],
    authorIds=[KUAQECPE],
    updatedDate=2023-11-22T10:15:30Z,
    status=Completed,
    briefDescription=,
    hasAttachments=false,
    description=,
    superTaskIds=[IEAB3RLHKRFJFQWA],
    createdDate=2023-11-22T01:40:40Z,
    sharedIds=[KX7YR6YJ, KUAQECPE, KUAQ4Z3V, KUAD6ID5, KX75EFI5, KUAEFHWO, KUAC7RH2], 
    followedByMe=false,
    parentIds=[IEAB3RLHI5FLM4XT],
    importance=Normal,
    metadata=[],
    scope=WsTask,
    superParentIds=[IEAB3RLHI5CYJSHS],
    customStatusId=IEAB3RLHJMD3AYDL,
    accountId=XXXXXXXX,
    responsibleIds=[KUAQECPE],
    permalink=https://www.wrike.com/open.htm?id=1111111111,
    priority=8ac0c0007ffffffffffbf000,
    dependencyIds=[], 
    completedDate=2023-11-22T10:15:30Z,
    id=IEAB3RLHKRFM5MH3
}
 ```

より詳細なデータが取れていることがわかります。
弊社の場合、この「customFields」にタスクの見積もり時間を入力してもらっています。
そのため、完了工数を算出するには、このフィールドを参照するだけで良いでしょう。

以下のようにカスタムIDから値を取得する関数を定義しておけば、簡単に取得ができるようになります。

```calculate.gs
// カスタムIDの値を取得する
function GetCustomDataValue_(task, customId) {
  let customDataArray = task['customFields']
  for (let i = 0; i < customDataArray.length; i++) {
    let customData = customDataArray[i]
    if (customData.id == customId) {
      return customData.value;
    }
  }

  return -1
}
```

このようにカスタムフィールドを利用することで、タスクに紐づく追加情報を定義、取得することが可能です。

## ポイント2： 完了時刻を変換する
Wrikeの完了時間のフォーマットは次のようになっています。
`2023-08-03T06:00:13Z`

このフォーマットから、日時を比較するためのDateフォーマットに変換する関数を定義します。

```
// Wrikeの完了時間からDateを取得する (Wrike Format: 2023-08-03T06:00:13Z)
function GetDateByWrikeFormat_(completeTime) {
  let tSplits = completeTime.split('T')
  let date = tSplits[0]
  let time = tSplits[1]

  let dateSplits = date.split('-')
  let timeSplits = time.split(':')

  let year = dateSplits[0]
  // Dateは0 ~ 11で定義される
  let month = Number(dateSplits[1]) - 1
  // Dayは1 ~ 31で定義される
  let day = dateSplits[2]
  let hour = timeSplits[0]
  let minute = timeSplits[1]
  let second = timeSplits[2].replace('Z', '')

  // Logger.log(year + "/" + month + "/" + day + " " + hour +":" + minute + ":" + second)

  var targetDate = new Date(year, month, day, hour, minute, second)
  // Logger.log(targetDate)
  return targetDate
}
```

これで日付比較が可能になりました。

# 7. 計算結果を書き込む関数を定義
ここまでに計算した結果を書き込む関数をいくつか用意しましょう。

まず、スプリント情報の書き込み関数を次のように定義します。
```calculate.gs
// スプリント情報 (総ポイント数、総工数を書きこむ)
function WriteSprintInfo_(sheet, burnDownInfo, totalDays) {
  // 総工数の書き込み
  sheet.getRange(cellDefine.totalManHour).setValue(burnDownInfo.totalTime)
  // 総日数の書き込み
  sheet.getRange(cellDefine.totalDyas).setValue(totalDays)
}
```

次にバーンダウンの情報を書き込む関数を次のように定義します。
```calculate.gs
// バーンダウン情報を書き込む
function WriteCompletedTime_(sheet, row, burnDownInfo) {
  // 残工数の列
  let col = ConvertToNumber_(cellDefine.leftTimeStart.substring(0, 1))

  // 書き込み済みならスキップ
  var blankFlag = sheet.getRange(row, col).isBlank()
  if (!blankFlag) {
    return
  }

  // 残工数の書き込み
  sheet.getRange(row, col).setValue(burnDownInfo.totalTime - burnDownInfo.completedTime)
}
```

最後に当日の総工数を書き込む関数を定義します。

```
// 当日の総工数を書き込む
function WriteTotalTimeAtDay_(sheet, row, burnDownInfo) {
  // 総工数の列
  let col = ConvertToNumber_(cellDefine.totalTimeStart.substring(0, 1))
  
  // 書き込み済みならスキップ
  var blankFlag = sheet.getRange(row, col).isBlank()
  if (!blankFlag) {
    return
  }

  // 当日の総工数を書き込み
  sheet.getRange(row, col).setValue(burnDownInfo.totalTime)  
}
```

# 8. 今までの関数を組み合わせた、最終形の関数を定義
これまでの関数を組み合わせた公開関数を定義します。

```calculate.gs
function Calculate() {
  let sheet = SpreadsheetApp.getActiveSheet()

  // 対象となるスプリントのWrikeのPermalink URLを取得する
  const permalink = sheet.getRange(cellDefine.permalink).getValue()
  let taskList = GetFolderTasks_(permalink)

  let totalDays = GetTotalDays_(sheet)
  for (let i = 0; i <= totalDays; i++) {
    // 総工数と完了工数を算出する
    let sprintDay = GetSprintDay_(sheet, i)

    // sprintDayに終わったタスクを取得するので、1日後に設定する
    sprintDay.setDate(sprintDay.getDate() + 1)

    // 当日以上のタスクは記入しない
    let thresholdDay = new Date()
    thresholdDay.setHours(0, 0, 0)
    if (thresholdDay <= sprintDay) {
      Logger.log("skip calculate: " + thresholdDay)
      continue
    }

    // ターゲットとなる日付の行
    let targetDayRow = Number(cellDefine.leftTimeStart.substring(1)) + i

    var burnDownInfo = GetCompletedTimeByDay_(taskList, sprintDay)
    WriteSprintInfo_(sheet, burnDownInfo, totalDays)
    WriteTotalTimeAtDay_(sheet, targetDayRow, burnDownInfo, false)
    WriteCompletedTime_(sheet, targetDayRow, burnDownInfo, false)
  }
}
```

あとはこれをGASの定期実行、あるいはボタンを押下して起動するようにすれば出来上がりです。

# まとめ
WrikeのAPIを連携し、あとはGoogle App Scriptでちまちま作っていくことで、バーンダウンチャートを自作することができました。

ここには細かく記載しませんでしたが、プロダクトバックログとスプリントのタスクは別のプロジェクトとして分けて運用しています。
このあたりのタスクの付け方なども、需要があれば今後触れていこうかなと思います。

最後にいつものごとく、[Grenge](https://www.grenge.co.jp)で一緒に働くメンバーも募集していますので、興味を持って頂けた方は是非弊社のサイトもご覧ください。

それでは、最後まで読んで頂き、ありがとうございました。


# スクリプト全文
<details><summary>スクリプト全文はこちら</summary><div>

```calculate.gs
// API Reference: https://developers.wrike.com/

/******* 定義クラス *******/
/* 工数情報 */
class BurndownInfo {
  //コンストラクタを定義
  constructor(totalTime, completedTime) {
      this.totalTime = totalTime
      this.completedTime = completedTime
  }
}

/* セル情報 */
class CellDefine {
  //コンストラクタを定義
  constructor(permalink, scheduleStart, leftTimeStart, totalTimeStart, totalManHour, totalDays, doneTotalTimeCell) {
      this.permalink = permalink
      this.scheduleStart = scheduleStart
      this.leftTimeStart = leftTimeStart
      this.totalTimeStart = totalTimeStart
      this.totalManHour = totalManHour
      this.totalDyas = totalDays
      this.doneTotalTimeCell = doneTotalTimeCell
  }
}
/******* 定義クラス *******/

/******* グローバル変数 *******/
// 読み取り
const permalinkCell = 'C2'        // スプリントパーマリンク (Wrike)
const scheduleStartCell = 'C8'    // スケジュール開始セル

// 書き込み
const leftTimeStartCell = 'L8'    // 残工数の開始セル
const totalTimeStartCell = 'J8'   // 総工数の開始セル (その日時点)
const totalManHourCell = 'I3'     // 総工数
const totalDyasCell = 'I4'        // 総日数
const doneTotalTimeCell = 'L3'    // 完了総工数

// セルの情報を定義する
const cellDefine = new CellDefine(permalinkCell, scheduleStartCell, leftTimeStartCell, totalTimeStartCell,  totalManHourCell, totalDyasCell, doneTotalTimeCell)
/******* グローバル変数 *******/

function Calculate() {
  let sheet = SpreadsheetApp.getActiveSheet()

  // 対象となるスプリントのWrikeのPermalink URLを取得する
  const permalink = sheet.getRange(cellDefine.permalink).getValue()
  let taskList = GetFolderTasks_(permalink)

  let totalDays = GetTotalDays_(sheet)
  for (let i = 0; i <= totalDays; i++) {
    // 総工数と完了工数を算出する
    let sprintDay = GetSprintDay_(sheet, i)

    // sprintDayに終わったタスクを取得するので、1日後に設定する
    sprintDay.setDate(sprintDay.getDate() + 1)

    // 当日以上のタスクは記入しない
    let thresholdDay = new Date()
    thresholdDay.setHours(0, 0, 0)
    if (thresholdDay <= sprintDay) {
      Logger.log("skip calculate: " + thresholdDay)
      continue
    }

    // ターゲットとなる日付の行
    let targetDayRow = Number(cellDefine.leftTimeStart.substring(1)) + i

    var burnDownInfo = GetCompletedTimeByDay_(taskList, sprintDay)
    WriteSprintInfo_(sheet, burnDownInfo, totalDays)
    WriteTotalTimeAtDay_(sheet, targetDayRow, burnDownInfo, false)
    WriteCompletedTime_(sheet, targetDayRow, burnDownInfo, false)
  }
}

// スプリント情報 (総ポイント数、総工数を書きこむ)
function WriteSprintInfo_(sheet, burnDownInfo, totalDays) {
  // 総工数の書き込み
  sheet.getRange(cellDefine.totalManHour).setValue(burnDownInfo.totalTime)
  // 総日数の書き込み
  sheet.getRange(cellDefine.totalDyas).setValue(totalDays)
}

// バーンダウン情報を書き込む
function WriteCompletedTime_(sheet, row, burnDownInfo) {
  // 残工数の列
  let col = ConvertToNumber_(cellDefine.leftTimeStart.substring(0, 1))

  // 書き込み済みならスキップ
  var blankFlag = sheet.getRange(row, col).isBlank()
  if (!blankFlag) {
    return
  }

  // 残工数の書き込み
  sheet.getRange(row, col).setValue(burnDownInfo.totalTime - burnDownInfo.completedTime)
}

// 当日の総工数を書き込む
function WriteTotalTimeAtDay_(sheet, row, burnDownInfo) {
  // 総工数の列
  let col = ConvertToNumber_(cellDefine.totalTimeStart.substring(0, 1))

  // 書き込み済みならスキップ
  var blankFlag = sheet.getRange(row, col).isBlank()
  if (!blankFlag) {
    return
  }

  // 当日の総工数を書き込み
  sheet.getRange(row, col).setValue(burnDownInfo.totalTime)  
}

// スプリントの合計日数を取得する
function GetTotalDays_(sheet) {
  let rowOffset = 0
  let dayCount = 0
  while (GetSprintDay_(sheet, rowOffset) != '') {
    dayCount++
    rowOffset++
  }

  // Day0から計算しているので、-1が必要
  return dayCount - 1
}

// スプリントの日程を取得する
function GetSprintDay_(sheet, rowOffset) {
  const cellCol = ConvertToNumber_(cellDefine.scheduleStart.substring(0, 1))
  let rowIndex = Number(cellDefine.scheduleStart.substring(1)) + rowOffset
  let dayString = sheet.getRange(rowIndex, cellCol).getValue()
  return dayString
}

// 指定した日付までに完了したタスクの工数を取得する
function GetCompletedTimeByDay_(taskList, targetDay) {
  // 総工数と完了工数を算出する
  let totalTime = 0
  let completedTime = 0
  
  for (let i = 0; i < taskList.length; i++) {
    let task = taskList[i]
    
    let taskId = task['id']
    let detailTask = GetTask_(taskId)

    // キャンセルしたタスクは考慮しない
    if (detailTask['status'] == 'Cancelled') {
      continue
    }

    let estimateTime = GetCustomDataValue_(detailTask, 'IEAB3RLHJUAETX5F')
    if (estimateTime < 0) {
      continue
    }

    let estimateTimeSplits = estimateTime.split(':')
    let hour = Number(estimateTimeSplits[0])
    let minute = Number(estimateTimeSplits[1])
    let man_hour = hour + minute / 60.0

    totalTime += man_hour
    // 完了済みのタスク計算
    if (detailTask['status'] != 'Active') {
      let completeDate = GetDateByWrikeFormat_(detailTask['completedDate'])
      if (completeDate < targetDay) {
        completedTime += man_hour
      }
    }
  }

  Logger.log(totalTime + " " + completedTime)
  return new BurndownInfo(totalTime, completedTime)
}

// カスタムIDの値を取得する
function GetCustomDataValue_(task, customId) {
  let customDataArray = task['customFields']
  for (let i = 0; i < customDataArray.length; i++) {
    let customData = customDataArray[i]
    if (customData.id == customId) {
      return customData.value;
    }
  }

  return -1
}

// Wrikeの完了時間からDateを取得する (Wrike Format: 2023-08-03T06:00:13Z)
function GetDateByWrikeFormat_(completeTime) {
  let tSplits = completeTime.split('T')
  let date = tSplits[0]
  let time = tSplits[1]

  let dateSplits = date.split('-')
  let timeSplits = time.split(':')

  let year = dateSplits[0]
  let month = Number(dateSplits[1]) - 1
  let day = dateSplits[2]
  let hour = timeSplits[0]
  let minute = timeSplits[1]
  let second = timeSplits[2].replace('Z', '')

  // Logger.log(year + "/" + month + "/" + day + " " + hour +":" + minute + ":" + second)

  var targetDate = new Date(year, month, day, hour, minute, second)
  // Logger.log(targetDate)
  return targetDate
}

// Permalinkから特定フォルダ以下のタスクリストを取得する
function GetFolderTasks_(permalink) {
  // 特定フォルダ以下のタスクリストを取得する
  let folderId = GetFolderId_(permalink)
  let urlPath = '/folders/' + folderId + '/tasks'
  let response = SendRequest_(urlPath, 'GET')
  let jsonData = JSON.parse(response.getContentText())
  let dataArray = jsonData['data']
  return dataArray
}

// PermalinkからフォルダのIDを取得する
function GetFolderId_(permalink) {
  let urlPath = '/folders/?permalink=' + permalink
  let response = SendRequest_(urlPath, 'GET')

  let jsonData = JSON.parse(response.getContentText())
  let folderId = jsonData['data'][0].id
  return folderId
}

// タスクIDからタスクを取得する
function GetTask_(taskId) {
  let urlPath = '/tasks/' + taskId
  let response = SendRequest_(urlPath, 'GET')

  let jsonData = JSON.parse(response.getContentText())
  return jsonData['data'][0]
}

// APIのリクエストを送信する
function SendRequest_(urlPath, method, payload) {
  let baseUrl = 'https://www.wrike.com/api/v4' + urlPath
  let options = CreateOptions_(method, payload)
  let response = UrlFetchApp.fetch(baseUrl, options)
  return response
}

// APIリクエスト時のオプションを作成する
function CreateOptions_(method, payload) {
  const accessToken  = '一番初めに作成したアクセストークンを入力する';

  let headers =
  {
    'Authorization': 'Bearer ' + accessToken 
  };

  let options =
  {
    'method': method,
    'headers': headers,
    'payload': payload,
  };

  return options
}

// アルファベットを数値に変換する (1始まり)
function ConvertToNumber_(strCol) {  
  var iNum = 0
  var temp = 0
  
  strCol = strCol.toUpperCase()
  for (i = strCol.length - 1; i >= 0; i--) {
    temp = strCol.charCodeAt(i) - 65 // 現在の文字番号;
    if(i != strCol.length - 1) {
      temp = (temp + 1) * Math.pow(26,(i + 1))
    }
    iNum = iNum + temp
  }
  return iNum + 1
}
```
</div></details>