GCP + Fitbit + Notion でお手軽健康管理

## Motivation

年末に購入した Google Pixel Watch 2 であるが、それまで使用していた Apple Watch との違いを特に感じず、モヤモヤしていたのだが、Pixel Watch には Fitbit アプリがデフォルトで入っており、睡眠データなどのヘルスデータや歩数などのアクティビティデータを取得してくれるので、それを使って何かしたいと考えた。
Fitbit アプリや web のダッシュボード(https://www.fitbit.com) では各日のデータを確認できるが、複数日にまたがって一覧でまとめてデータを俯瞰したり、他の記録（e.g. 習慣トラッカーなど）と合わせて記録を残したい場合にはそれがなかなか難しいので、**それらを全て Notion に突っ込むことで自分だけの活動記録テーブルを作ろうと考えた。**

## TL;DR
タイトルにあるツール群を用いて、ヘルスデータ（歩数、睡眠時間、ランニング時間など）を自動的に管理下に置くためのパイプラインを敷設した。
具体的には、「データを Fitbit API で取得して、それを Notion API で書き込む」サーバーレス環境を構築した。

## 前提条件

本パイプライン構築のために必要となるものについて記載する。
今回は Fitbit と Notion を利用しているが、仮に Fitbit ではなく Garmin だろうが、Notion ではなく Google Sheets だろうが、その部分を自分でカスタマイズすれば同様のパイプラインの構築は可能だと思う。

### 開発環境

- 開発環境: Mac/MacOS 14.0
- npm: 9.8.1
- node: v18.18.0

### その他必要なもの

- Fitbit 対応デバイスでデータを取得していること(今回は Google Pixel Watch2(Wi-fi モデル) を使用)

## アーキテクチャ

![test.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/36abc9ce-66f9-a4b8-ce67-a53d75b7ec87.png)

大まかな流れは、以下の通り。
1. Cloud Scheduler(定刻のPub/Sub トリガー起動)
2. Pub/Sub(Cloud Scheduler からのメッセージを受けて、Cloud Functions 関数をキックする)
3. Cloud Functions(Fitbit データの取得、Notion へのデータ書き込み)
4. Firestore(Fitbit API refresh token の保存と取得)


## 各種セットアップ

ここから実際に手を動かして構築していく部分の説明となる。

:::note warn
各ツール群の詳細な手順とはなっていないため、不明点は別途他の情報に当たって頂きたい。(特に GCP まわりは説明端折ってます)
:::


### Fitbit
- [こちら](https://accounts.fitbit.com/signup)から開発者アカウントを作成する

    <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/a057ac38-93f0-2743-107d-b82ed9c5fe32.png" width="250">

- [こちら](https://dev.fitbit.com/apps/new)からアプリケーションを新規登録する
    - 色々入力項目があるが、**基本的にテキトーで良い**。URL は `http://localhost:8080/` などにしておいたら良い

        <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/4122f843-a2cf-7e41-ee59-41525215be69.png" width="500">



- 登録が完了すると、`MANAGE MY APPS` ページに `OAuth 2.0 Client ID` と `Client Secret` が作成されているので確認する

    <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/96cf5b72-b7e1-0fd1-de17-1607337469b4.png" width="500">


- [こちら](https://dev.fitbit.com/build/reference/web-api/troubleshooting-guide/oauth2-tutorial/)からリフレッシュトークンを発行する
    - step 4 で `refresh_token` を発行したら、それを後で利用するため控えておく[^1]
- また、[こちら](https://www.fitbit.com/)で Fitbit のアクティビティデータが入っていることを確認しておくと良いだろう

[^1]: 初回のみリフレッシュトークンを手動で取得することで、OAuth 認証認可プロセスにおけるユーザーの同意を得るプロセスをスキップしている。一度リフレッシュトークンが手に入れば、あとは API 経由でトークンを再発行すれば良い。


### Notion
- ページを作成する
    - ページID(`https://www.notion.so/` に続く**32文字の英数文字列**)は後に利用するので控えておく
        
        <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/9b38006f-9043-392d-5181-f879ff0155bc.png" width="400">


- 作成したページ配下でデータベースを作成する
    - データベースID(`https://www.notion.so/` に続く**32文字の英数文字列**)は後に利用するので控えておく
    
        <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/989abc1d-c8f6-20f5-681c-56d906a3de75.png" width="300">


- 作成したデータベースに対して
    - デフォルトで一番左側のプロパティ(`Name` という名前で、Type=`Title`)を `Day` に変更する
        - Type=`Title` のプロパティは、データベースにおけるキーとなる
    - 以下のプロパティを追加する
        - `RunningTime`: その日にランニングした時間(分)
            - Type: `Number`
        - `SleepDuration`: 前日からその日にかけての睡眠時間(分)
            - Type: `Number`
        - `Steps`: その日の歩数
            - Type: `Number`

    このようなイメージになる。
    <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/91fa4c7e-9b8b-8fee-c7fb-de086f289520.png" width="500px">

- シークレットを作成する
	- [こちら](https://www.notion.so/my-integrations)で新規インテグレーションを作成
        <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/1d34d8cb-bae6-e888-159e-64e7f60079ee.png" width="500px">
    - `Associated workspace`から作成したページやデータベースが属するワークスペースを選択し、`Name` に任意の名前を入力して Submit する
    - 作成されたインテグレーションページに行き、`Internal Integration Secret`の内容を控える

### GCP

#### Cloud Functions

##### 環境変数
必要な以下の項目を環境変数を `.env` に格納する

:::note warn
本来アクセストークンやそれに準ずる秘匿性の高い情報を環境変数で扱うのはあまりセキュリティ的に良くないので、個人利用以外のプロジェクトの場合は GCP では [Secret Manager](https://cloud.google.com/secret-manager?hl=ja)などの利用の検討をお勧めする。
:::

```sh
COLLECTION_PATH=credentials
FITBIT_DOC_ID=fitbit
NOTION_DOC_ID=notion
CLIENT_ID=XXXXXX
CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTION_ACCESS_TOKEN=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTION_PARENT_PAGE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
PUBSUB_TOPIC_NAME=habit-tracker-pubsub-topic # for パラメータ化された構成
```

ここで、各変数は以下のような意味である:
- `COLLECTION_PATH`: Firestore ルートコレクション名
- `FITBIT_DOC_ID`: Firestore Fitbit 関連のドキュメント名
- `NOTION_DOC_ID`: Firestore Notion 関連のドキュメント名
- `CLIENT_ID`: Fitbit OAuth 2.0 Client ID
- `CLIENT_SECRET`: Fitbit client secret
- `NOTION_ACCESS_TOKEN`: Notion API access token(secrets)
- `NOTION_PARENT_PAGE_ID`: Notion ページの Page ID。取得時は 32 文字の英数文字列のため、これを UUID の形式に変換して記述する(適当な位置に`-`を挿入する)
- `PUBSUB_TOPIC_NAME`: Pub/sub トピック名

##### コーディング

要所を少し紹介([Github](https://github.com/yasuki0707/habit-tracker)で全ソースコード公開)

- Fitbit アクティビティデータ取得
    - fitbit.ts > fetchData で、`dateForFitbit` で指定された日付のデータを取得する
    - sleep.ts > fetchSleepData は Fitbit API[^2] とのインターフェース

[^2]: 本エンドポイントの詳細はこちら: https://dev.fitbit.com/build/reference/web-api/activity/get-daily-activity-summary/

```fitbit.ts
import { fetchSleepData } from '@/api/fitbit/sleep';

export const fetchData = async (dateForFitbit: string, accessToken: string) => {
  const sleep = await fetchSleepData(dateForFitbit, accessToken);
  const sleepDurationInMin = sleep.summary.totalMinutesAsleep;
  ...
};
```

```sleep.ts
export const fetchSleepData = async (dateString: string, accessToken: string) => {
  const url = `https://api.fitbit.com/1.2/user/-/sleep/date/${dateString}.json`;
  const response = await fetch(url, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });
  const data = await response.json();
  return data;
};
```

- Notion への書き込み
    - notion.ts > updateDBPage で `targetDate` で指定された日の記録を更新する
        - `updatePageData` の詳細は[公式ページ](https://github.com/makenotion/notion-sdk-js/blob/main/src/Client.ts#L439)を参照
    - page.ts > updatePage は、Notion API とのインターフェース

```notion.ts
import { updatePage } from '@/api/notion/page';

export const updateDBPage = async (targetDate: DayjsDate) => {
  // filter pages in database that matches "`day`日" for property 'Day'.
  const accessToken = await issueTokenPair(oldRefreshToken);
  const { ..., sleepDurationInMin } = { ...(await fetchData(dateForFitbit, accessToken)) };
  const day = targetDate.format('D');
  const pageIds = await findPageByDay(day);

  const updatePageData = {
    page_id: pageIds[0],
    properties: {
      ..
      SleepDuration: {
        number: sleepDurationInMin,
      }
    }
  };

  await updatePage(updatePageData, NOTION_ACCESS_TOKEN);
};
```

```page.ts
import { Client } from '@notionhq/client';
import { UpdatePageParameters } from '@notionhq/client/build/src/api-endpoints';

export const updatePage = async (params: UpdatePageParameters, accessToken: string) => {
  const notion = new Client({
    auth: accessToken,
  });

  const response = await notion.pages.update(params);
};
```


##### デプロイ

デプロイにより、Pub/Sub トピックは自動生成される

```sh
# Firebase の CLI ツールのインストール
npm install -g firebase-tools

# デプロイ(Firebase Cloud Function のプロジェクトを CLI で作成すると、デフォルトでデプロイコマンドがセットされている)
npm run deploy
```

#### Firestore コレクション/ドキュメント用意

以下の構造のコレクションを作成する。
[このセクション](#Fitbit)で取得したリフレッシュトークンと、[このセクション](#Notion)で取得したデータベースID をあらかじめ入れておく。
**これらの情報は初回のみ格納すればよく、以降はプログラム実行時自動更新される。**

```sh
COLLECTION_PATH # root collection
├── FITBIT_DOC_ID # document
│   └── refreshToken: <xxxxxxxxxxx...>
└── NOTION_DOC_ID # document
    └── databaseId: <xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx>
```

#### Cloud Scheduler

定刻に処理実行されるように job 定義(e.g. `10 0 * * *` = 毎日0:10)

### あとは深夜0:10 を待つのみ！

Cloud Scheduler によりメッセージが Pub/Sub トピックめがけて送信されると、以下の処理が実行される:
- (毎日)その日のデータ行を一行追加し、前日のデータを書き込む
- (毎月1日のみ)データベース（テーブル）を追加する

これにより、月替わり対応も自動で行い、基本的にはどんどん新しいデータを書き込んでいってくれるようになっている。

## データ書き込みされた Notion 画面イメージ

Fitbit のデータが書き込むのと併せて、習慣トラッカーの項目もチェックボックスで組み込んでいる。
現状はこれらは手でぽちぽちやっているが、これも API 経由で操作可能なので、何かの判断材料をもとに自動で記録することができる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/6749b107-a6cf-3b6a-0c6d-b5093da86a61.png)

:::note info
Notion では各プロパティは Type に基づいて集計関数が用意されているため、Number 型データの場合、平均値や合計値などをテーブル下方に表示することができる。
また、実際に時間に関するデータは単なる分表記ではなく「HH時間MM分」などの形式にした方が可読性は高いが、その変換を Cloud Functions 側で行った結果を Notion に書き込む場合 `Type=Text` となるため、上述の集計関数に制限が出てしまうのがモヤモヤポイントだ（平均睡眠時間などが出せない）。
:::

## 所感

- Notion はシステムインテグレーション周りが非常によく整備されていて、API エンドポイントを直たたきせずとも SDK が用意されており、型補完を効かせることができて開発しやすい
- データ挿入位置がデータベース上意図しない場所になされるため、順番に挿入されるようにしたい
- Fitbit 以外のデータを連携してみて何か面白い示唆が現れるかをみてみたい