スポーツの開催日程と出欠を管理できるアプリ作ってみた

# 背景・目的

私は休みの日にバレーボールを楽しみ、加齢による運動不足解消やストレス解消を行なっている。
できるだけ頻度多く運動ができるよう複数のチームに参加しているが、そうなるといつどのチームの開催があるか各チームのLINEグループの出欠表を見る必要がありちょっとめんどい。

無料の出欠ツールを利用し開催日程をまとめて確認できるようにもしてきたが、使い勝手や無料であるがゆえ広告が表示されたりと煩わしさを感じていた。
（広告が悪いとは言っていない　無料サービスであれば仕方ないもの）

なので、自分好みのものを作ってしまえばいい。

最近興味を持ったGoogle AppSheetを利用してみるとうまくいきそうな気がしたのでやってみた。
そんな投稿である。

尚、一部コードや設定の掲載を行なっているが、あまり細かな箇所までは掲載しきっていない。
AppSheetってこういうこともできるんだぐらいの感覚で認知してもらえれば幸い。

# AppSheetとは

https://cloud.google.com/appsheet?hl=ja

コードなしでアプリを構築することが可能なもの。
スクラッチ開発に比べると柔軟性は低いが、凝ったことをしなければ十分。

ただし、今回はちょっとだけ凝ります。

# 概要

アプリ名はバレーボール365という名称にした。
いつでもバレーボールを楽しもうというコンセプト。

やりたいことのイメージはこんな感じ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/35fad13c-baab-4e5c-8e7a-acf35c9fbf4f.png)

尚、出欠登録ができるようにはなっているが、最終的には各チームで用意されている出欠登録の仕組みに従うため、出欠管理はあくまで個人管理の位置付け。

# 機能一覧

- 開催日程確認（カレンダー表示）
カレンダー形式でいつどのチームの開催があるかを確認できる。
- 出欠登録
今日以降の開催日程に対し出欠を登録できる。
- 開催前日リマインド（LINE通知）
翌日に開催がある場合、前日の18:00頃にLINE公式アカウントからリマインドを通知できる。
- メンテナンス系
  - 開催日程登録
  - チーム登録
  - 開催場所登録
  - ユーザ登録

# 機能詳細

## 開催日程確認

カレンダー形式で表示し、ラベルはチーム名が表示される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/aaa43749-523a-4b6f-9fc8-286d7d637113.png)

画面上部の`Day` `Week` `Month`を選択すると表示形式が変わる。
`Week`を選択した例。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/2a6b785e-8557-458c-9c7c-c8c028c774ec.png)

チーム毎で色を変えたかったので、ViewOptionsのCategoryを設定。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/7f6d4a1d-a4cf-4127-946c-424e132672c8.png)

<!--
`Description`に指定した列がカレンダー上に表示される文字列となるが、開催日程を持っているテーブルの`チームid`を指定するとID文字列が表示されるため、チーム名を参照するよう`dispチーム`仮想列を作成し割り当てている。

```:dispチーム仮想列のFORMULA
[チームid].[チーム]
```
-->

## 出欠登録

自身の出欠を登録できる。

この画面は一番悩み苦労した画面。
自分の出欠を一覧上で俯瞰、編集したいという思いがあったのと、他の人の出欠状況も把握したい思いがあった。

試行錯誤の末に出来上がった画面がこちら。
自身の出欠を一覧上で確認、更新ができる。
自身含めた当日の出欠状況は`開催日程 [出/欠/未]`列の表記で確認できる。（画像は自分しか登録していないため1しか表記されていないが、複数人数登録していると2,3と表記が増えていく）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/c53062fb-4b71-43ce-80fa-6ae3566206d1.png)

編集画面（画面右上の鉛筆アイコンから）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/d1226517-d27c-422b-852d-1a72f33ebffa.png)

誰が出席なのかを詳細に確認するには一覧の行を選択した先の画面にて確認できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/29cdcf03-f329-42a5-b33a-98b20cdba9ad.png)

以下、色々苦労した話。

### 苦労話①
通常生成される画面の構成だと、1つ1つの開催日程に対して編集フォームへ遷移し出欠登録・更新していく流れになるが、これだと毎回画面遷移が発生するためメンドイ。
表現方法の試行錯誤やAppSheetの設定を色々探ったところ、一覧上で更新できる機能があったためそれを利用した。

- Viewの設定
`View type`はテーブルタイプに設定。
`QuickEdit機能（ベータ）`をONにする。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/4073851a-3c5d-42a8-b4cf-a258eb794863.png)

- 出欠テーブルの設定
編集可能な列を`出欠`列のみにする。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/e70ace4c-3bdf-4374-b947-e9a837f34d77.png)

### 苦労話②
出欠一覧には、本日以降のすべての開催日程が掲載された状態としたかった。

特に手を加えていない状態だと、本一覧には登録済みの出欠しか表示されないため出欠未登録が発生する可能性がある。
（ただし、実際の運用においては本アプリで最終的な出欠を管理せず、各チームで定められた出欠システムに登録するため、あまり重要ではないが一応こだわった）

そのため、開催日程追加（後述の開催日程登録）をした際に、各ユーザ毎（後述のユーザ登録）に未入力状態の出欠レコードを作成するようGASを組んだ。

AppSheetのオートメーションにおいても同様のことができないかと色々調査したが、`ユーザ`テーブルに登録されているユーザ分レコードを作成する処理を組むことができなかった。
（やれる方法があればアップデートしたい）

- オートメーション設定－日程テーブルの追加検知

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/e75e3ece-e5fa-47c0-89b7-f75a2d976051.png)

- オートメーション設定－検知後の出欠レコード追加処理

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/bb016dab-8b5d-4f0c-bb71-1d6c5d91dc22.png)

GASの`insertAttendance`を呼び出すように設定してある。

追加されたスケジュールID`scheduleId`を受け取るようになっているため、オートメーション側から追加されたレコードの日程id（`[_THISROW].[日程id]`）を渡すように設定している。

- GAS処理

<details><summary>ソースコード</summary>

```js:（GAS）出欠レコード追加処理
function insertAttendance(scheduleId) {
  Logger.log("日程ID[" + scheduleId + "]");

  const ss = getSpreadSheet();
  const userSheet = ss.getSheetByName("ユーザ");
  const attendanceSheet = ss.getSheetByName("出欠");

  // 出欠シートの書き込み開始行
  let idx = attendanceSheet.getLastRow() + 1;

  // ユーザテーブルのユーザ分ループし出欠レコードを追加
  for(let i = 2; i <= userSheet.getLastRow(); i++) {
    // 出欠idは行番号と同じ番号にする
    attendanceSheet.getRange(idx, 1).setValue(idx - 1);
    // 日程idは追加された日程idを割り当て
    attendanceSheet.getRange(idx, 2).setValue(scheduleId);
    // ユーザのメールアドレスを割り当て
    attendanceSheet.getRange(idx, 3).setValue(userSheet.getRange(i, 1).getValue());
    // 出欠は空の状態にする
    attendanceSheet.getRange(idx, 4).setValue("");
    idx++;
  }
}

function getSpreadSheet() {
  const spid = PropertiesService.getScriptProperties().getProperty("SP_SHEET_ID");
  return SpreadsheetApp.openById(spid);
}
```
</details>

## 開催前日リマインド
翌日に開催がある場合、前日18:00ごろにLINEの公式アカウントからリマインド通知を送信する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/c477d48d-d2bf-4f8d-98e4-32c0977210ce.png)

アプリへのリンクも掲載し、メッセージからすぐにアプリを開き確認できるようにした。

- LINEアカウントの準備

割愛

下記記事はLINEグループに参加している公式アカウントからのメッセージ配信について掲載されているが、アカウントの作成方法やメッセージング処理については大変参考になった。
一旦下記記事を一通り試し、感覚を掴むと良いと感じる。
感謝！

https://qiita.com/MikH/items/d9876b6e50f7c8510d0b

- LINE公式アカウントからのメッセージ配信

https://developers.line.biz/ja/reference/messaging-api/#send-broadcast-message

- GAS処理

長いので説明は割愛

<details><summary>ソースコード</summary>

```js:（GAS）LINE公式アカウントからのメッセージ配信処理
function dailyNotification() {
  const nowDt = new Date();
  const nextDt = new Date(nowDt.getFullYear(), nowDt.getMonth(), nowDt.getDate() + 1);

  const ss = getSpreadSheet();
  const scheduleSheet = ss.getSheetByName("日程");

  let notifyDate = "";
  let notifyCount = 0;

  const teamDict = getTeamNameDict();
  const placeDict = getPlaceNameDict();

  for(let i = 2; i <= scheduleSheet.getLastRow(); i++) {
    const scheduleDate = scheduleSheet.getRange(i, 2).getValue();
    if(nextDt.getTime() === scheduleDate.getTime()) {
      const teamId = scheduleSheet.getRange(i, 3).getValue();
      const startTime = scheduleSheet.getRange(i, 4).getDisplayValue();
      const endTime = scheduleSheet.getRange(i, 5).getDisplayValue();
      const placeId = scheduleSheet.getRange(i, 6).getValue();

      notifyDate += `${startTime}-${endTime} ${teamDict[teamId]} ${placeDict[placeId]}\n`;
      notifyCount++;
    }
  }

  if(notifyCount > 0) {
    let notifyMessage = `明日(${nextDt.getFullYear()}/${nextDt.getMonth() + 1}/${nextDt.getDate()})の開催リマインドです。\n`;
    notifyMessage += notifyDate;
    notifyMessage += "\nこのメッセージは出欠状況に関係なく送信されます。\n\n";
    notifyMessage += "アプリへのリンクはこちら\nhttps://xxxxxxxxxxxxxx"
    sendOfficialMessage(notifyMessage);
    Logger.log("リマインド対象あり");
    Logger.log(notifyDate);
  } else {
    Logger.log("リマインド対象なし");
  }
}

function sendOfficialMessage(message) {
  const CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("CHANNEL_ACCESS_TOKEN");

  let url = "https://api.line.me/v2/bot/message/broadcast";
  let headers = {
    "Content-Type": "application/json",
    "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN,
  };

  let postData = {
    "messages": [{
      "type": "text",
      "text": message
    }]
  };

  let options = {
    "method" : "post",
    "headers": headers,
    "payload" : JSON.stringify(postData)
  };

  UrlFetchApp.fetch(url, options);
}

function getSpreadSheet() {
  const spid = PropertiesService.getScriptProperties().getProperty("SP_SHEET_ID");
  return SpreadsheetApp.openById(spid);
}

function getTeamNameDict() {
  const ss = getSpreadSheet();
  const teamSheet = ss.getSheetByName("チーム");

  const dict = {};
  for(let i = 2; i <= teamSheet.getLastRow(); i++) {
    dict[teamSheet.getRange(i, 1).getValue()] = teamSheet.getRange(i, 2).getValue();
  }
  return dict;
}

function getPlaceNameDict() {
  const ss = getSpreadSheet();
  const placeSheet = ss.getSheetByName("場所");

  const dict = {};
  for(let i = 2; i <= placeSheet.getLastRow(); i++) {
    dict[placeSheet.getRange(i, 1).getValue()] = placeSheet.getRange(i, 2).getValue();
  }
  return dict;
}
```
</details>

- GASトリガー設定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/ed22ca43-4b46-49d4-8255-eb630462f3dc.png)

## メンテナンス系

### 開催日程登録

開催日程の登録ができる。
登録後、前述の<a href="#苦労話-1">苦労話②</a>にて掲載した日程テーブルの追加検知オートメーションに繋がり、ユーザ毎の出欠レコードが作成される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/bdabcb7a-9668-45c3-ad9a-aaa2fc84d57d.png)

### チーム登録

特に説明する箇所はなし。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/6c088ff8-62f4-46fa-956b-de9a18b5df84.png)

### 開催場所登録

特に説明する箇所はなし。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/f0c08f07-a2e1-40b1-adb9-e2b84b56aa13.png)

### ユーザ登録

開催日程登録と似たような仕組みで、追加されたユーザに対しては全日程の空白出欠レコードが登録されるようオートメーションを仕込んでいる。
仕組みやGASのコードはほぼ同様となるため詳細は割愛とする。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38724/3e07de32-97ec-4dab-82a5-c1fc04c3380c.png)

# 総括

ノーコードツール（厳密には今回ローコード）ってすごい。
しかもこれが無料でできてしまうのがまたすごい。

かゆい所には手が届きにくいが爆速で作成できるというメリットが大きく、どうしても届かない箇所は ~~GASでどうにでもなる~~ アイデアを駆使すれば何とかなる。

まだまだ知らない機能があるため、今後もアプリを作成しAppSheetの熟練度向上を図りたい。

皆さんも作ってみてください。