月報の自動集計 : Notion 解説 (43)

(2/2 修正、🍅の記事数ではなく🍅の数を合計するように変更しました)

1. はじめに

現状、月報は他のタスクと同じデータベースに自動生成されています。今日は月末なので、正しく生成されています(注: この記事は1/31から書き始めました)。

f:id:hkob:20220131102459p:plain
作成された月報タスク

月報のテンプレートは週報を取り込むようになっていますが、実を言うとあまり機能していません。週報くらいは直近の記録なので、まだ記憶を思い出せるのですが、月報はあまりうまく振り返れていませんでした。

そんな中、Rei さんのこのツイートを見て、私もやってみたくなりました。そもそも月報は日々のタスクがうまく動いているかどうか程度の確認のみでよく、自動化してしまってもいいのではという考えです。これを踏まえて、システムの改修をしてみることにします。

2. 現状把握と改修のポイント

2.1 繰り返しタスク生成

繰り返しタスクは Google Spreadsheet の表で管理しています。基準日からの周期に一致するとき、または特定の月、週、日付にマッチした時にタスクが生成されます。今回、以下の修正を行う予定です。

  • [A.1] 月報はタスクとは別テーブルにするため、この行を削除します。
  • [A.2] API が使えるようになった初期のスクリプトのため、Notion API のバージョンを現在の最新版に修正します
  • [A.3] プロジェクトの行を追加し、タスク登録と同時にプロジェクトへのリレーションも自動設定するようにします。

f:id:hkob:20220131103611p:plain
繰り返しタスク登録シート

2.2 月報テーブルの作成

タスクの親となる月報を作ろうと確認していたところ、すでにリレーションがあり、以下の月報テーブルに繋がっていました。どうやら Yuka さんの YouTube を見ながら勉強していた時に作ったものでした。昨年度3月・4月までは使っていた形跡がありますが、その後はタスクベースの月報に移行していたので使っていなかったものです。今回はこれを有効利用します。

f:id:hkob:20220131105608p:plain
既存の月報テーブル

ここについては、以下の修正を行う予定です。

  • [B.1] クオータはほとんど使っていないので、リレーションだけでなく、データベースごと削除します。
  • [B.2] その月の月報ページがなかったら自動的に生成します。
  • [B.3] 雑務・振り返りページのみ、作成した時に自動的に月報へのリンクを追加します。

2.3 前日作業の統計処理

雑務・振り返りタスクには、ページを作るほどでもないタスクを記録します。Alfred から「ar 補講連絡を moodle に投稿」のようにすると、雑務のページに箇条書きが追記されます。

f:id:hkob:20220131104637p:plain
Alfred の ar コマンド

また、大きなタスクについては、ポモドーロで実施しています。プロジェクトのページではプロジェクトごとのポモドーロの総数などはロールアップで集計できますが、それほどプロジェクトを小分けにしていないので、あまり有意なデータではありませんでした。

f:id:hkob:20220131105110p:plain
ポモドーロの記録

これらの部分は以下のように修正する予定です。

  • [C.1] 記録あり(boolean)、実行🍅数(Number)のプロパティをタスクデータベースに追加します。
  • [C.2] 前日の振り返りページを取得し、本文が1行でも記録されていたら、ページの記録ありプロパティを true にします。
  • [C.3] 前日のタスクを全部取得し、「🍅」が記録されているタスクの数を数え、実行🍅数に記録します。

2.4 月報ページのプロパティ作成

月報ページのプロパティを修正します。

  • [D.1] 雑務・振り返りページの記録ありプロパティをロールアップで集計します。
  • [D.2] 同様に、雑務・振り返りページの実行🍅数プロパティをロールアップで集計します。

3 実装

GAS の修正部分に関するところだけピックアップして説明します。[] 内は上の改修項目に相当します。

3.1 sendNotion [A.2]

Notion Version を最新版の 2021-08-16 に修正しました。また、Notion へのアクセスごとに400ms 待つことで、個別に時間待ちするのを止めました。また、payload を持たない場合も出てきたので、その場合には payload に null を入れることで対応しました。

// Notion に payload を send する
function sendNotion(url, payload, method){
  let options = {
    "method" : method,
    "headers": {
      "Content-type": "application/json",
      "Authorization": "Bearer " + myNotionToken(),
      "Notion-Version": "2021-08-16",
    },
    "payload" : payload ? JSON.stringify(payload) : null
  };
  // デバッグ時にはコメントを外す
  //Logger.log(options)
  Utilities.sleep(400); // 時間待ちはここに移動した
  return JSON.parse(UrlFetchApp.fetch(url, options))
}

3.2 createPayload [A.3]

create 時の payload 作成時に project_id を渡せるようにしました。存在する時には、Project の relation hash に project_id を追加します。これまでのメソッドがそのまま使えるように project_id は一番最後の引数とし、省略可能とします。このため、カレンダーからタスクを作成する doCalendarPost は何も修正する必要がなくなりました(カレンダーからの生成時はプロジェクトを指定しない)。

// title, date_hash と project_id から create payload を作成 (project_id は省略可能)
function createPayload(title, date_hash, project_id = null) {
  let relation = project_id ? [{"id": project_id}] : []
  return {
    "parent": {
      "database_id": databaseId()
    },
    "properties": {
      "タスク名": {
        "title": [
          {
            "text": {
              "content": title
            }
          }
        ]
      },
      "日付": {
        "type": "date",
        "date": date_hash
      },
      "Project": {
        "type": "relation",
        "relation": relation
      }
    }
  }
}

3.3 storeTokenAndIds, projectId, monthlyId [A.3, B.2, B.3]

プロジェクトテーブルと月報テーブルの ID を、これまでの Token とタスク ID と同様にスクリプトプロパティに登録しておきます。また、これらをプロパティから取得できる getter メソッド projectId と monthlyId も作っておきます。記述ができたら、実行してプロパティ登録を事前に実施しておきます。ログで登録できたことが確認できたら、セキュリティの確保のために文字列は消しておきます。

// MY_NOTION_TOKEN と DATABASE_ID をプロパティに登録する(1回だけ使い、終わったらIDを消す)
function storeTokenAndId() {
  const scriptProperties = PropertiesService.getScriptProperties()
 scriptProperties.setProperties({
    "MY_NOTION_TOKEN": "ここに「Notion Token」を記述",
    "DATABASE_ID": "ここに「データベースID」を記述",
    "PROJECT_ID": "ここに「プロジェクトID」を記述",
    "MONTHLY_ID": "ここに「月報ID」を記述"
  })
  // 登録できたことを確認
  console.log("myNotionToken = " + myNotionToken())
  console.log("databaseId = " + databaseId())
  console.log("projectId = " + projectId())
  console.log("monthlyId = " + monthlyId())
}

// PROJECT_ID を取得
function projectId() {
  return PropertiesService.getScriptProperties().getProperty("PROJECT_ID")
}

// MONTHLY_ID を取得
function monthlyId() {
  return PropertiesService.getScriptProperties().getProperty("MONTHLY_ID")
}

3.4 getPages [A.3, B.2, B.3]

前回まではタスクテーブルのみの読み込みでしたが、今回はプロジェクトテーブル、月報テーブルも読み込むので、データベースを指定できるようにします。ただし、これまでの互換性から省略時にはタスクデータベースを読み込むようにしておきます。

// Notion のデータベースを query するメソッド (payload にフィルタや並び順を設定しておく)
function getPages(payload, id = null) {
  let connectId = id || databaseId()
  let url = "https://api.notion.com/v1/databases/" + connectId + "/query" // API URL
  return sendNotion(url, payload, "POST")
}

3.5 getProjectByName [A.3]

スプレッドシートに書かれたプロジェクト名からプロジェクトページ配列を取得します。先ほど修正した getPages の第二引数に projectId() の戻り値を渡します。

// 名前からプロジェクトを取得する
function getProjectByName(name) {
  let payload = {
    "filter": {
      "property": "name",
      "text": {
        "equals": name,
      },
    }
    }
  return getPages(payload, projectId())
}

3.6 getProejctId [A.3]

getProejctByName を使ってページから id を取得し返します。ただし、文字列が空の時や、ページが見つからない場合には null を返します。

function getProjectId(name) {
  var ans = null
  if (name != "") {
    let page = getProjectByName(name)["results"][0]
    ans = page ? page["id"] : null
  }
  return ans
}

3.7 スプレッドシートの修正 [A.3]

スプレッドシートにプロジェクト名を追加します。空欄の場合には検索しないことにします。連続してデータを取得したいので、これまでコメントを書いていたところをプロジェクトにして、コメントをJ列に移動しました。

f:id:hkob:20220131211435p:plain
シートにプロジェクト名を追加

3.8 selectEvents [A.3]

スプレッドシートからタスクを生成する関数を修正します。H列までを取得するようにして、H 列のデータから proejct_id を取得します。取得した proejct_id を createPayload に渡すことで、プロジェクトの自動生成を実現します。これで A の修正は全て終わりました。

// date の日付がテーブルの行ごとに当てはまるか確認し、当てはまれば Notion にポスト
// デバッグテスト用に date を変えながらテストしていた
function selectEvents(date) {
  var sheet = SpreadsheetApp.getActiveSheet()
  var lastrow = sheet.getLastRow()
  for (let row = 1; row < lastrow; row++) {
    let lines = sheet.getRange("A"+(row+1)+":H"+(row+1)).getValues()[0]
    if (isValidEvent(lines, date)) {
      let title = lines[0] + ((lines[1] && lines[1].toFixed() == 1) ? Utilities.formatDate(date, "JST", " M/d (" + weekToStr(date) + ")") : "")
      let date_hash = {"start": Utilities.formatDate(date, "JST", "yyyy-MM-dd")}
      let project_id = getProjectId(lines[7])
      createPage(createPayload(title, date_hash, project_id))
      sheet.getRange("I" + (row+1)).setValue(date)
    }
  }
}

3.9 getMonthlyId [B.2, B.3]

雑務・振り返りページに月報ページへのリレーションを張るために、月報ページの id を取得します。月が変わってページがない場合にもあるので、その場合には作成します。

function getMonthlyId(date) {
  let name = Utilities.formatDate(date, "JST", "YYYY - MM")
  let payload = {
    "filter": {
      "property": "Months",
      "text": {
        "equals": name,
      },
    }
  }
  var ans = null;
  let page = getPages(payload, monthlyId())["results"][0]
  if (page) {
    ans = page["id"]
  } else {
    let newPayload = {
      "parent": {
        "database_id": monthlyId(),
      },
      "properties": {
        "Months": {
          "title": [
            {
              "text": {
                "content": name
              }
            }
          ]
        }
      }
    }
    ans = createPage(newPayload)["id"]
  }
  return ans
}

3.10 getReflectionPageId [B.3]

振り返りページに月報リンクを追加するため、振り返りページの id を取得します。このページは存在が確定しているので、エラー処理はしていません。

function getReflectionPageId(date) {
  let payload = {
    "filter": {
      "and": [
        {
          "property": "タスク名",
          "text": {
            "starts_with": "雑務・振り返り",
          },
        },
        {
          "property": "日付",
          "date": {
            "equals": Utilities.formatDate(date, "JST", "YYYY-MM-dd")
          },
        },
      ]
    }
  }
  return getPages(payload, databaseId())["results"][0]["id"]
}

3.11 setMonthlyPageRelationToReflectionPage [B.3]

ここまでの関数を用いて、月報ページへのリンクを雑務・振り返りページに追加します。

function setMonthlyPageRelationToReflectionPage(date) {
  let reflectionPageId = getReflectionPageId(date)
  let payload = {
    "properties": {
      "月報": {
        "type": "relation",
        "relation": [
          {
            "id": getMonthlyId(date)
          }
        ]
      }
    }
  }
  return updatePage(reflectionPageId, payload)
}

3.12 test [B.3]

テストも兼ねて、1月1日から1月31日までの雑務・振り返りタスクのリレーションを全部再設定するテスト関数を作って実行しておきます。

function test() {
  var date = new Date("2022/1/1")
  Logger.log(date.getMonth())
  while (date.getMonth() == 0) {
    Logger.log(date)
    setMonthlyPageRelationToReflectionPage(date)
    date.setDate(date.getDate() + 1)
  }
}

1月の月報を見ると正しくリレーションが貼られていることがわかります。1月31日だけ個別に単体テストしていたので、順番が狂っていますね。 この辺りはまだテストだということで、このままにしておきます。これを見るとやはり振り返り記述できていないですね。2月から頑張ります。

f:id:hkob:20220201080805p:plain
作成されたリレーション

3.13 日々呼ばれる関数に追加

setMonthlyPageRelationToReflectionPage を selectEvents の後ろに追加しました。繰り返しタスク登録が終わった後に、月報へのリンクを追加する関数を呼び出します。 1月31日のもくもくタイム(ポモドーロ)ではここまでしか終わりませんでした。続きは2月1日のポモドーロで実装します。

// これまで通り、毎朝呼ばれる関数
function doEverydayPost() {
  let today = new Date()
  selectEvents(today)
  setMonthlyPageRelationToReflectionPage(today)
  updateDelayedTask()
}

ということで、ここから2月1日の記録です。朝の自動実行で 2 月分の月報が作られ、2/1(火)の雑務とのリレーションも無事に張られています。 これで B の修正が全部終わりました。

f:id:hkob:20220201081124p:plain
2月1日の月報

3.14 プロパティの追加 [C.1]

タスクデータベースに記録あり(boolean)と実行🍅数(Number)のプロパティを追加しました。意味があるのは雑務・振り返りページだけですが、データベースの構造上仕方ないことです。普段は見えている必要はないので、常に非表示にしておきます。

f:id:hkob:20220201092649p:plain
プロパティの追加

3.15 getBlockChildren [C.2]

雑務・振り返りページに記述があるかどうかをブロックの子要素のチェックをすることで判断します。ユーティリティ関数を作っていなかったので、作っておきます。このメソッドは payload がないので、null を渡しています。sendNotion の引数の順序が逆だったら省略できてよかったですね。後の祭りです。返り値の results の個数で存在が確認できるはずです。

// Notion のブロックの子供一覧を query するメソッド (payload にフィルタや並び順を設定しておく)
function getBlockChildren(blockId, pageSize = 50) {
  let url = "https://api.notion.com/v1/blocks/" + blockId + "/children?page_size=" + pageSize // API URL
  Logger.log(url)
  return sendNotion(url, null, "GET")
}

3.16 getTheNumberOfPomoPages [C.2]

次に同日のポモドーロの数を数えます。当日の🍅が存在するページの数をカウントするだけです。

function getTheNumberOfPomos(date) {
  let payload = {
    "filter": {
      "and": [
        {
          "property": "🍅",
          "multi_select": {
            "is_not_empty": true,
          },
        },
        {
          "property": "日付",
          "date": {
            "equals": Utilities.formatDate(date, "JST", "YYYY-MM-dd")
          },
        },
      ]
    }
  }
  return getPages(payload, databaseId())["results"].reduce(function(sum, p) { sum + p["properties"]["🍅"]["multi_select"].length }, 0)}

3.17 updateStatistics [C.3]

上記二つの関数を呼び出し、二つの変数に代入しています。この値を 3.14 で作成したプロパティに登録するだけです。

function updateStatistics(date) {
  let reflectionPageId = getReflectionPageId(date)
  let isRecorded = getBlockChildren(reflectionPageId, 1)["results"].length == 1
  let numOfPomos = getTheNumberOfPomos(date)
  let payload = {
    "properties": {
      "記録あり": {
        "checkbox": isRecorded
      },
      "実行🍅数": {
        "number": numOfPomos
      }
    }
  }
  updatePage(reflectionPageId, payload)
}

3.18 test2 [C.2, C.3]

3.12 と同様に、テストを兼ねて 1 月分の振り返りページの統計情報を一括設定してみます。

function test2() {
  var date = new Date("2022/1/1")
  Logger.log(date.getMonth())
  while (date.getMonth() == 0) {
    Logger.log(date)
    updateStatistics(date)
    date.setDate(date.getDate() + 1)
  }
}

3.19 doEventdayPost [C.2, C.3]

動作確認できたので、明日の朝から前日分の集計を自動実行するようにしておきます。 明日の朝にうまく動いているかを確認することにします。

// これまで通り、毎朝呼ばれる関数
function doEverydayPost() {
  let today = new Date()
  selectEvents(today)
  setMonthlyPageRelationToReflectionPage(today)
  let yesterday = new Date()
  yesterday.setDate(today.getDate() - 1)
  updateStatistics(yesterday)
  updateDelayedTask()
}

3.20 月報ページの修正 [D.1, D.2]

作業が済んだので、月報ページに一気にロールアップを設定します。作成してみて、1月はポモドーロの記録や雑務の記録をサボりまくっていたことが判明しました。やはりこうやって可視化されていないと、サボりがちになりますよね。今日からしっかり記録をつけていくことにします。

f:id:hkob:20220201095739p:plain
月報ページのロールアップ

4. 終わりに

これで、月報ページは毎日自動集計されるはずです。LaunchPad の今日のタスクの下に集計値だけ表示するようにしました。今日は 2/1 なので 0 % ですが、明日の朝には更新された情報が表示されます。これで記録することをサボらなくなればいいですね。

f:id:hkob:20220201100248p:plain
LaunchPad への表示


www.notion.so