Vue3 + ApexCharts.js + Toggl でGitHub風の草はやす

# 背景
GitHubの草ってソースのコミットでしか生やすことできなんだよなあ。
ソースを書いてる時間とか勉強している時間も草みたいに見えるようにしたいなあ。
ということで、普段 [Toggl track](https://toggl.com/track/) という作業時間管理のツールを利用しているので、Togglで記録した作業時間をGitHubの草みたいな感じでヒートマップに表示してみた。


# 技術スタック
- Vue.js 3
- Vite
- Pinia
- TypeScript
- ApexCharts.js
- Day.js
- SCSS

# つくったもの
1. 「タグ取得」をクリックしてTogglで登録しているタグ一覧を取得する
2. 任意のタグをクリック
3. 任意の期間を選択
4. タグと期間に基づくTogglの作業時間を取得して、ヒートマップ形式で表示する

![流れ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/360809/edf122f8-c418-7617-7dfa-e9e35a34fde2.gif)


# 開発の流れ
### まずはPJ作成
[Viteの公式ページ](https://ja.vitejs.dev/guide/#最初の-vite-プロジェクトを生成する)を参考にして Vue.js 3 + TypeScript の環境を作成する。
```
# yarn
yarn create vite my-vue-app --template vue-ts
```

状態管理ツールの [Pinia](https://pinia.vuejs.org/getting-started.html) をインストール
```
yarn add -D pinia
```
main.tsでPiniaをimportする
```typescript:src/main.ts
import { createApp } from "vue";
import App from "./App.vue";
import { createPinia } from "pinia";

const app = createApp(App);
app.use(createPinia());
app.mount("#app");
```

グラフ描画のために [apexcharts.js](https://apexcharts.com/docs/installation/) をインストールする
```
yarn add -D apexcharts
yarn add -D vue3-apexcharts
```

日付の変換処理を多用するので [Day.js](https://github.com/iamkun/dayjs/blob/4acbb7246887b5cf0923f5313f992b9478100cee/docs/ja/README-ja.md) をインストールする
```
yarn add -D dayjs
```

ひとまずこれでPJの作成は完了
次は草っぽいグラフを作成していきます。

### VueでHeatmap風グラフ作成
グラフを描画したいvueファイルで、script内でapexchartsのimportとデータの設定をする

```vue:hoge.vue
<script setup lang="ts">
import VueApexCharts from "vue3-apexcharts";
const apexchart = VueApexCharts;

const chartOptions = {
 // グラフのオプションを記述
};
const series = [
 // グラフのデータを記述
];
</script>
```

template内で`<apexchart>`にパラメータを渡してあげればグラフが描画される
```vue:hoge.vue
<template>
  <apexchart
    width="500"
    type="heatmap"
    :options="chartOptions"
    :series="series"
  ></apexchart>
</template>
```

Heatmapのグラフを作成する場合は以下を指定する
- type: "heatmap"を指定する
- options: ヒートマップの色やレンジを指定する
- series: ヒートマップのデータを指定する


**optionsの例**
ヒートマップのグラフは、データの値の大きさによって色を変化させるので plotOptions で色付けの範囲と色を指定する
```js
const chartOptions = {
  plotOptions: {
    heatmap: {
      colorScale: {
        ranges: [
          {
            from: -30,
            to: 5,
            color: "#CCFFFF",
            name: "low",
          },
          {
            from: 6,
            to: 20,
            color: "#CCFF00",
            name: "medium",
          },
          {
            from: 21,
            to: 45,
            color: "#66CC00",
            name: "high",
          },
        ],
      },
    },
  },
};
```

**seriesの例**
`name` で縦軸の表示名
`data` のxで横軸の表示名、yでデータの値
を表す
```js
const series = [
    {
      name: "Series 1",
      data: [{
        x: 'W1',
        y: 22
      }, {
        x: 'W2',
        y: 29
      }, {
        x: 'W3',
        y: 13
      }, {
        x: 'W4',
        y: 32
      }]
    },
    {
      name: "Series 2",
      data: [{
        x: 'W1',
        y: 43
      }, {
        x: 'W2',
        y: 43
      }, {
        x: 'W3',
        y: 43
      }, {
        x: 'W4',
        y: 43
      }]
    }
  ]
```

### Toggl API連携
Togglのデータを取得するには [Toggl Reports API v2](https://github.com/toggl/toggl_api_docs/blob/master/reports.md) を利用する
TogglのアカウントさえあればAPIも無料で利用可能

APIを呼び出す際にはAPIトークンとワークスペースIDが必要
APIトークンはToggl truck画面のアカウントアイコン > Profile setting から確認することができる
ワークスペースIDはReport画面のURL末尾に表示されている

登録されているタグを取得するときは以下の要領で取得可能
```terminal
$curl -u [APIトークン]:api_token -X GET "https://api.track.toggl.com/api/v8/workspaces/[ワークスペースID]/tags"

[
	{
		"id": xxxxxxx,
		"wid": xxxxxxx,
		"name": "tag1",
		"at": "2021-06-30T00:33:21+00:00"
	},
	{
		"id": yyyyyyy,
		"wid": yyyyyyy,
		"name": "tag2",
		"at": "2021-06-30T00:32:57+00:00"
	}
]
```

Tagで絞ってデータを取得するときは以下
```
$ curl -u [APIトークン]:api_token -X GET "https://api.track.toggl.com/reports/api/v2/details?user_agent=api_test&workspace_id=2080468&since=2022-03-11&until=2022-03-25&tag_ids=xxxxx"
```
リクエストパラメータの詳細はこちらのドキュメントに記載されている
https://github.com/toggl/toggl_api_docs/blob/master/reports.md

### Toggl APIのデータとApexCharts連携
Toggl APIで取得するデータとApexChatsのインプットデータ（series）は形式が異なるので変換する必要がある。
GitHubの草風に見せるために、縦軸は曜日、横軸は週となるようデータ整形を行なった。
```typescript:src/store/index.ts
// 日付処理用
import dayjs from "dayjs";

const generateData = (togglData: Toggl) => {
  const formattedData: Heatmap[] = [];
  const _dayNames: string[] = [];
  const _weeks: string[] = [];

  // toggl apiのデータから作業した曜日と週を取得
  togglData.data.forEach((togglItem: TogglData) => {
    const dayName: string = dayjs(togglItem.start).format("dddd");
    if (!_dayNames.includes(dayName)) _dayNames.push(dayName);

    const week: string = dayjs(togglItem.start)
      .startOf("week")
      .format("YYYY/MM/DD");
    if (!_weeks.includes(week)) _weeks.push(week);
  });

  // 曜日と週を見やすい順序に並び替え
  const sortedDays = [
    "Monday",
    "Tuesday",
    "Wednesday",
    "Thursday",
    "Friday",
    "Saturday",
    "Sunday",
  ];
  _dayNames.sort((a: string, b: string) => {
    return sortedDays.indexOf(b) - sortedDays.indexOf(a);
  });
  _weeks.sort();

  // 作業した曜日ごとのデータを作成
  _dayNames.forEach((element) => {
    formattedData.push({ name: element, data: [] });
  });

  // 作業した週ごとのデータを作成
  _weeks.forEach((element) => {
    formattedData.forEach((heatmapItem) => {
      heatmapItem.data.push({
        x: element,
        y: 0,
      });
    });
  });

  // formattedData内の該当する曜日・週のデータにtogglデータをコピー
  togglData.data.forEach((togglItem: TogglData) => {
    const dayName: string = dayjs(togglItem.start).format("dddd");
    const week: string = dayjs(togglItem.start)
      .startOf("week")
      .format("YYYY/MM/DD");
    const workingTime = Math.floor(togglItem.dur / 1000 / 60);

    formattedData.forEach((heatmapItem: Heatmap) => {
      if (heatmapItem.name === dayName) {
        heatmapItem.data.forEach((heatmapDataItem: HeatmapData) => {
          if (heatmapDataItem.x === week) {
            heatmapDataItem.y += workingTime;
          }
        });
      }
    });
  });
  return formattedData;
};
```

Toggl API呼び出しとApexChatsへの変換関数はstoreに記載する
vueファイルからstoreのseriesを呼び出すことでtogglデータからヒートマップのグラフを描画できるようにした
```typescript:src/store/index.ts
import { defineStore } from "pinia";
// グラフ作成用
import axios, { AxiosResponse, AxiosError } from "axios";

export const useStore = defineStore("main", {
  state: () => {
    return {
      togglTags: [] as Tag[],
      togglData: {} as Toggl,
    };
  },
  getters: {
    series: (state): ApexAxisChartSeries => {
      if (!state.togglData.data || state.togglData.data.length === 0) return [];
      return generateData(state.togglData);
    },
    perPage: (state) => state.togglData.per_page,
    totalCount: (state) => state.togglData.total_count,
  },
  actions: {
    // タグの取得
    getTogglTagInfo() {
      const workspace_id = import.meta.env.VITE_TOGGL_WORKSPACE_ID;
      axios
        .get(
          `https://api.track.toggl.com/api/v8/workspaces/${workspace_id}/tags`,
          {
            ~~timeoutやauthのパラメータを記載~~
          }
        )
        .then((res: AxiosResponse<Tag[]>) => {
          this.togglTags.length = 0;
          console.log("get tag success log: ", res.data);
          this.togglTags.push(...res.data);
        })
        .catch((error: AxiosError<{ error: string }>) => {
          console.log("get tag error log: ", error.message);
        });
    },
    // 作業時間の取得
    getTogglData(params: {}) {
      axios
        .get("https://api.track.toggl.com/reports/api/v2/details", {
          ~~timeoutやauth, getのパラメータを記載~~
        })
        .then((res: AxiosResponse<Toggl>) => {
          console.log("get data success log: ", res.data);
          this.togglData = { ...res.data };
        })
        .catch((error: AxiosError<{ error: string }>) => {
          console.log("get data error log: ", error.message);
        });
    },
  },
});

```

APIのインパラに環境変数`import.meta.env.VITE_TOGGL_WORKSPACE_ID`を使用しているが、
Viteで環境変数を扱う場合には変数名の先頭に「VITE_」を付与しないと読み込まれないので注意が必要
参考）https://ja.vitejs.dev/guide/env-and-mode.html#env-files

Viteの環境変数の読み込み箇所にTypescriptの補完を当てたい場合は新しく定義を加える
```typescript:src/env.d.ts
interface ImportMetaEnv {
  readonly VITE_TOGGL_API_TOKEN: string;
  readonly VITE_TOGGL_WORKSPACE_ID: number;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
```

参考）[https://ja.vitejs.dev/guide/env-and-mode.html#typescript-用の自動補完](https://ja.vitejs.dev/guide/env-and-mode.html#typescript-%E7%94%A8%E3%81%AE%E8%87%AA%E5%8B%95%E8%A3%9C%E5%AE%8C)

### Toggl API連携でCORSエラーになった
ここまでで開発環境上でTogglのデータから草を生やすことができたので、次はプロダクト環境で配信する。
プロダクト環境への配信にはAWSを利用。[こちらの記事](https://qiita.com/okubot55/items/8d059a48bb5f22d3278b) を参考にしてCloudFormationで S3 + CloudFront 環境を構築した。

S3に`yarn build`コマンドで生成したdistをアップロードし、CloudFrontで提供されたURLで画面を開いてみたが、API通信の部分で以下のエラーが出た。
```
Access to XMLHttpRequest at 'https://api.track.toggl.com/api/v8/workspaces/xxxxx/tags' from origin '[CloudFrontのURL]' has been blocked by CORS policy: Response to preflight request doesn't pass access control check
```
CORS。。。開発環境上では問題なく通信ができていたのだが、CloudFrontのURLからの呼び出しではブロックされてしまった。
改めてToggl APIのドキュメントを熟読すると以下の指針が示してあった。
https://github.com/toggl/toggl_api_docs/blob/master/chapters/cors.md
重要なのは冒頭のこの部分。
```
This portion of the API is disabled, if you require CORS whitelisting for your website please contact support@toggl.com for further assistance.
```
要はサポートに連絡してくださいとのこと。
Togglは外国のサービスなのでドキドキしながら英語で「Whitelistに登録してくだしぃぃ」と連絡したら、翌日に軽い感じで登録したぜ連絡が来た。
さすが海外。あっさりしている。

無事プロダクト環境でもToggl APIを呼び出すことができるようになった。


以上が ”自分で使う用の” 草はやすサービスの紹介。
v2では他の人も使えるサービスを作り込んでいきたい。