Apps Scriptで作った健康観察アプリを振り返る

こちらは [Google Apps Script Advent Calendar 2023](https://qiita.com/advent-calendar/2023/google-apps-script) 22日目の記事です。

----

こんにちは！わたしはふだん小・中学校のICT支援員をしていて，支援業務ではたまにApps Scriptを書いたりしてます．わたしの担当する学校現場では，授業や業務問わず，日常的にGoogle系ツールが利用されています. ここでは，2023年に書いたApps Scriptのうち，学校の先生からの相談を受けて作成してみた，とあるWebアプリを振り返ります.

# まず結論
- いい感じの開発環境がわからん
- Apps ScirptのWebアプリは真面目にやるものではない．せいぜいこういう感じ，というものでいい
- 主にReactがわからん


このようなアプリを作りました．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636632/c3df22be-7e2a-4927-9fc5-bb78be31cb01.png)


# この記事で書かないこと
- Apps ScriptによるWebアプリの作成及びデプロイの具体的な手順
- Google系ツールの共有設定や実行権限など
    - 取り扱うデータは適切に処遇しましょう．ご安全に
- 開発環境を作成するための環境と手順[^KEY_yo]
- デプロイ後の継続的なサポート

# あらすじ

（↓ とくに技術的な内容を含まないので折りたたんでいます）

<details><summary>あらすじを読む</summary><div>

## 「健康観察をしたい」
先生からの相談は，学校のICT環境を使って生徒児童の毎日の健康観察を簡略化できないか，というものでした．それほど規模の大きくない学校ですが，それまではGoogle Sheetsにそれぞれの教員が直接書き込む形になっていて，非常に煩雑ということでした．Google Sheetsの関数を使ってある程度は入力サポートされているが，「壊れにくくて壊しやすい」SpreadSheetの使い方の典型でした．

後述しますが，わたしたちの自治体ではGoogle Workspace for Educationを採用しており，Workspaceの各サービスへリーチしやすいため，次善の案として「Google Formsをつかえばいいのではないか？」という意見もあったそうです．実際，ググると多数の先行事例がヒットします．Google Formsを健康観察用途に試験導入したり，保護者や生徒自身に記入してもらうかたちで正式に稼働していたりという実例も既にあるそうです．

しかしあくまで個人的な意見でいえば，今回のような用途にGoogle Formsを採用するには，いろいろ運用面で課題が想起されます．

## Google Formsにあらがう
「とっても気軽に投稿・収集できるツールを，とっても気軽に作成・編集できる」という点はGoogle Formsの称賛すべきところです．教育現場でもアンケートや課題提出など様々なシチュエーションで利用が広まっており，これによりいわゆる「フォーム」という一般名詞がGoogle Formsという固有名詞そのものを示すほど認知されています．Microsoft 365 Educationを採用しているならばMicrosoft Formsが似たような立ち位置になっていると思われます．

ただ，要件やユースケースによっては，Google Formsが向いてないことも多々あります．いろいろなことを一度にやろうとしたり，ちゃんとやればやろうとするほどしんどくなる気がします．長期的運用を考えるならばなおさらGoogle Formsの仕様や特徴が足かせになってきます．

- Google Formsのオーナー権限が譲渡できない
    - DocsやSheetsとは異なり，Formsではファイルのオーナー権限を別のアカウントに譲渡できない（内容のコピーは可能）
- 可搬性が悪い
    - SpreadSheetとFormsがセットになったようなApps Scriptの移行や配布は非常に面倒
- 作りやすいが壊しやすく壊れやすい
    - うっかり編集できてしまう
    - 下手なApps Sctiptを書いていると（エラーを握りつぶすとかログを取得してないとか），時限爆弾のように，あとになってから障害を引きおこす
    - 「ある程度の量の項目をたくさん何度も投稿することがある」というシチュエーションではGoogle FormsのUXが悪い
- フォームツールとしての機能は決して万能ではない
    - 項目の検索
    - バリデーションが貧弱
        - せいぜい単一の正規表現程度で，複雑な条件は指定できない
    - ユーザーによって提示する内容を振り分けられない（ふりがなとか）
    - 他の項目の値によって動的に内容を変えたい
- 各データの整合性
    - 今回のユースケースでは特に， 単純に「健康観察」の目的に合わない？
        - 日次基準なので同じ内容や一部だけ異なる内容でも一件ずつ投稿する必要がある
        - 期間が決まっている内容やきょうだい揃って同一の内容であってもそれぞれ一件ずつ投稿しなければならない
            - 備考に書いておいてそれを読めば〜，などは運用でカバーするのと同じで，どこかにしわ寄せが行き，不意に破綻する


もともと簡略化・自動化のために作っていたものが，ふと冷静に考えると運用で無理やりカバーすることでなんとか成り立っているようなことはよくあります．いわゆる技術的負債につながりやすいものに陥りがちです．

「これでなんでも解決できるぜ！」のような感じであらゆる場面で無闇矢鱈とGoogle Formsを作りまくる前に，一旦落ち着いてほかの手段で代替できないか考えるのも大事です．たしかに誰でも容易に使いはじめることができるうれしいツールですが，だからといってすべてを解決する銀の弾丸たり得るわけではありません．

[ハンマーしか持っていなければ、すべてが釘に見える - 西尾泰和のscrapbox](https://scrapbox.io/nishio/%E3%83%8F%E3%83%B3%E3%83%9E%E3%83%BC%E3%81%97%E3%81%8B%E6%8C%81%E3%81%A3%E3%81%A6%E3%81%84%E3%81%AA%E3%81%91%E3%82%8C%E3%81%B0%E3%80%81%E3%81%99%E3%81%B9%E3%81%A6%E3%81%8C%E9%87%98%E3%81%AB%E8%A6%8B%E3%81%88%E3%82%8B)


巷にはFormsとSheetsの連携をするApps Scriptがたくさんありますが，設計・実装のしやすさと保守運用を考えると，有効的にはたらくシチュエーションは実はそこまで広くのないでは？と思っています．例えば、「会議室の予約システムがほしい」といったシーンでGoogle Formsを導入したような事例を見かけたことがあるのですが，これは現在では[Google Calendarの予約スケジュール機能](https://workspace.google.com/intl/en/resources/appointment-scheduling/)で充分カバーできそうです． たしかにGooge Formsで回答 → Gmailに任意の内容で送信 → Google Sheetsに転記 → Google Docsから内容を差し込み印刷・PDFを生成しGoogle Driveに保存 → Google Calendarの特定のカレンダーに予定を作成 → Google Chat botで投稿 → Script Triggerでさらに通知、みたいなリッチすぎる活用をApp Scriptで実現することは可能ですが，リッチというかFatでしょう．無理やりパッチワークでつなげた感は否めません（自戒）．

Google Formsが向いてる用途だなと感じたのは，わたしが見聞きしたなかだと「いつでも誰でも書いてね」のようなアンケートや申込み or 問い合わせ、あるいは「ちゃんと投稿してね」みたいな投票や選択などです．いずれにしても匿名性だったり回答条件だったり考えなくてはならない他の要素はたくさんありますが，運用やデータ集計を考えると，わりと雑に回答を割り切り，微細なバリエーションは無視して統一されてるものが良いと感じます．これも個人の感想ですが，「Google Formsでどうやって実現しようかなぁ，Apps Script書けばまあできるけどなぁ」となった時点ですでにそれはGoogle Forms向きではありません．[^KEY_once_post_every_day]
Apps ScriptやそれによるGoogle Formsの生成・回答の取得方法を知るとと，ひとしきり設定やAPIをこねくりまわし「自動化！業務改善！RPA！プログラミング！」[^KEY_nya]: 「これでどんな形式のGoogle Formsでも一発でいろんな種類を数分で作れる！」「項目の仕様さえ決まっていれば，いくらでも追加できるぜ！」「一部の特殊な設定は個別に手で設定するぜ！」みたいになりがちです．「なんかやりにくいな？」と気づいたときはもう手遅れで，気軽に生成されたGoogle Formsたちは，夢と希望をもって産み落とされたにも関わらず，`FormApp.create`で名を授けられた瞬間から，回答者・運用する側にとって忌み嫌われる，呪われた存在なのです．
一般的な回答者の立場でみれば，Google Formsの項目が20を超えたあたりから不穏な気配を感じ，他のGoogle Formsが埋め込まれている時点で冷や汗が流れ，「3つまで選べ」のような選択肢要件を無理やり拡張して分割しそれぞれ必須項目としているセクションがあれば眉を顰め，自由記述が必須なのに特段のバリデーションをかけてない設定にため息が出，回答の複雑度を増していくと同時に「これ作成者が自己満でいろんな機能を試しているだけでは？」と疑心し，もはや途中は適当に回答をポチるだけの体でやっとの思いでたどりついた終盤でエラーを起こし，Googleアカウントにログインしているから途中までの回答は保存されているはずだと思いながら再度開くと完全にまっさらな時点からスタートしてしまう．そんなことが頻発するのです．集計側でも，80カラムを越える正規化されていないテーブルに対峙するということは少しばかり気合と能力が必要です．しかしながらたいていの場合テーブル設計よりも実装が先行されるうえどうせ変更も反映されません．さらに「一部のFormsだけフォントを変えください」「これはこのヘッダー画像を使ってください」「あらゆる画面で，回答者の学年に準拠した未習の漢字にはふりがなをつけてください」のようにApps Script側のAPIが提供されておらず管理画面から編集するほかない部分[^KEY_aaa]を「Google Formsだからできるでしょ？」ということで逐一設定することになるのです．
そしてやっとの思いで作ったApps Scriptは，策定者の事情や環境，あるいはGoogleの気まぐれな仕様変更や，ドキュメント化のされていない属人的コードといったなど様々な要因で，今後二度と使われることはないのです．


日に何度も投稿を余儀なくされ，かつほとんど固定的な内容で，しかも（学年・名前など）正確性が求められ，一定頻度で項目の内容が変わるような，今回のような案件では，Google Formsを無理やり使うと，作る前からしてむしろ運用の負担が大きくて無駄が多くなりそうなスメルを感じます．

## Apps ScriptとGoogle Sheets

Apps Scriptで作るWeアプリでは，よく見かける構成として，DB代わりにGoogle Sheetsを使うというものがあります．これはGoogle FormsとGoogle Sheetsの紐付け（Formsからの回答ごとにシートの行へ内容を反映する）でもおなじみですね．
　
技術的な安心と信頼を求めるならばFirebaseやBigQuery，Locker Studioなどが使えればいいのでしょうが，基本的に教育現場で採用されているエディション（Google Workspace for Education）だとGCPの利用はほぼないでしょう．個人的にはSpreadSheetのシートをDB代わりにするのはあんまりいただけないのですが，ここはVBAやApps Scriptのスタンダードに倣います．

ここでは，Google Sheetsのコンテナバインドスクリプトとして書いたApps Scriptで投稿フォームのWebアプリを作成するということを目指しました．

コンテナバインドスクリプトならばファイルをコピーしたときにスクリプトも付属します．Google Sheets1つなのでオーナー権限の委譲も可能だし，配布するときも一応簡単です[^KEY_kantan]．

ただし，SpreadSheetのDB的な性格は求めますが，正規化をまともに考えるようなことはしませんでした．そもそもSpreadSheetはDBではないので，インデックスやトランザクション, ビューといった概念をいい感じに適用する方法がわかりません．クエリを書けるわけでもないのでプリペアドステートメントも使えません．今回Google Sheetsは本来の表計算ソフトとしての用途を想定せず，あくまでデータの参照および保存だけを目的として使用します．表計算ソフトとしてのSpreadSheetの特性ならびに「実際にデータを扱う編集者はExcelなどの表計算ソフトの使用に慣れているはず」という前提から，データすなわちシートを直接いじってもらいやすくするためです．つまりシート上でデータを加工・抽出したり，見た目をわかりやすくする工夫や関数をつけるといったことはアプリ側では一切考えません．特にデータの加工や抽出・フィルタリングは，これといって正解がないし，アプリ側でソートなどの設定を決め打ちして自由度を狭めるよりは，編集者の好みと用途と能力に任せたほうがいいという判断からです．データは用意するので，編集はお好きにどうぞ，という感じです．


## 横展開
ツールの使用方法や設定なんかを広く共有したり，情報や資料を融通しあうのは簡単ですが，ことApps Scriptが絡んでくると，普及や導入のハードルが高くなりがちです．コピペで済むコードであっても，そのコードをどこに追加してどういう設定をすればいいのか，コピペしたつもりが過去のクリップボードの内容がペーストされて気づかない，調べてアレンジを加えたら動かなくなった，「書いてあるとおりにしたはずなのに自分の環境ではよくわからないエラーが出る」といった無限の問答が発生してしまうことは容易に想像できます．[^KEY_yes]もちろんこういった問題はApps Scriptに限ったことではないのですが，万人が足並みを揃えて適切に受け入れることができるかと言われるとかなり困難でしょう．導入したとして，個々の継続的なサポートやアップデート支援はまず現実的ではないです[^KEY_addon]．すべて自分の管理化にあるようなものならばTriggerやPropertyなんかを多用できますが，普及となると基本的に設定値は決め打ちになり，それぞれの都合を無視することになります．一旦そういった事情は無視して，アプリそのものに焦点をあてました．


</div></details>

ということで，今回作成したアプリは「じゃあGoogle Formsは一切使わず，フォーム自体を作ればいいじゃん．Google SheetsのContainer-binded script1つで済むし」というところから始まりました．


# 環境・ツール
## Googleアカウント環境
Google Workspace for Education Fundamentals です．

## 開発環境
vscodeとdevcontainerです．

## アプリ関連
React製のSPAです．

- React v18
- Chakra-ui
- vite v4
- pnpm

デザインコンポーネントにChakra-uiを使っています．自分はCSSが書けないのでバリバリ頼ります．

Apps Script向けの開発ツールとして導入したのは，以下です．
- [clasp](https://github.com/google/clasp)
- [vite-plugin-singlefile](https://github.com/richardtallent/vite-plugin-singlefile)
- [esbuild-gas-plugin](https://github.com/mahaker/esbuild-gas-plugin)
- [gas-client](https://github.com/enuchi/gas-client)

`clasp`と`vite-plugin-singlefile`は，Apps ScriptでWebアプリを作成する場合よく見かけるセットです．もちろんviteでSPAを作るという前提ですが．
残りの`esbuild-gas-plugin`と`gas-client`については後述します．


また使用している主なライブラリは
- react-hook-form
- yup
- chakra-react-select
- react-window
- date-fns

などです．ほか，こまごまとしたやつはリポジトリをごらんください．

# アプリ内容
モノレポで，Reactを書くディレクトリとApps Scriptのディレクトリに分けました．

```terminal
src
├── app
│   ├── client      　　クライアント側コード
│   │   ├── App.tsx
│   │   └── 略
│   └── server　　　　　 Apps Script側のコード
│       ├── API
│       └── types.ts
├── Config　　　　　　　　共通設定など
│   ├── Const.ts
│   ├── errors.ts
│   ├── MenuResponse.ts
│   ├── Response.ts
│   └── SheetData.ts
```

後述しますがついでにGoogle Sheetsから使う管理画面チックなカスタムメニュー用のディレクトリもあるんですが，作成が間に合ってないので，今回はこちらの内容はつっこみません．

## esbuild-gas-plugin
Apps Scriptの場合はesbuildでビルドし出力されたjsファイルはすべて即時関数内に閉じてしまうようです．この仕様はApps Scriptの天敵です．Apps Scriptではグローバルスコープにある関数のうち関数名がアンダースコアで終わっていないものだけを呼ぶことができるので，htmlを返すシンプルトリガーの`doGet`すら効いてくれません．

`esbuild-gas-plugin`はApps Scriptのこの仕様を回避するためのesbuildプラグインです。

使い方や導入はSmartHRさんの記事を参考にしました．

https://tech.smarthr.jp/entry/2022/07/01/132343

Apps Script側のファイルはviteではなくesbuildでビルドすることになります．

```json:package.json
{
  "scripts": {
    "front-build": "tsc && vite build --emptyOutDir --config vite.config.ts && cp -r ./dist_app/* ./dist && rimraf ./dist_app",
    "server-build": "node build.cjs ",
    "build": "bun run front-build & bun run server-build",
    // 略
}
```

ビルド用のスクリプトはこちらのものをそのまま使わせていただきました．

https://qiita.com/Syuparn/items/80c4f48c66ca53ef12b8#esbuild-gas-plugin%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9

```js:build.cjs
const { GasPlugin } = require('esbuild-gas-plugin');
require('esbuild')
  .build({
    entryPoints: ['src/Main.ts'],
    bundle: true,
    outfile: 'dist/main.js',
    logLevel: 'info',
    plugins: [GasPlugin],
  })
  .catch(() => process.exit(1));
```

### 補足：Apps ScriptのES準拠

<details><summary>ちょっと横道に外れるので折り畳んでいます</summary><div>


今回の開発はモノレポで進めており，ビルドはフロントもApps Scriptも同じtsconfigでコンパイルしています．フロントは常に`"target":"ESNext"`でヨシとしているのですが，Apps Scriptランタイムで対応されているECMAScript準拠のバージョンはよくわかってません．探しきれなかったけど，どこかで公式にアナウンスされているのかもしれないです...

これについてググってみました．V8の開発者であるjmrk氏のStackoverflowでの回答が参考になりそうでした．

https://stackoverflow.com/questions/71089066/which-version-of-ecmascript-does-the-google-apps-script-v8-runtime-use

> A reasonably recent version, and it gets updated every so often. I believe the idea is to track or slightly lag behind stable Chrome releases, but (as with any large project updating its dependencies) there may occasionally be hiccups/delays. 

> the V8 version that GAS uses is sufficiently new (by at least two years) to support it; but the overall GAS experience depends on more than V8: the editor is parsing the entered source in order to provide help or highlighting or error checking or whatnot. It looks like the GAS team is aware that certain features aren't supported yet by the components responsible for that, and is actively working to remedy that. (I have no idea what the timeline is.)

（ChatGPTにいい感じに要点をまとめてもらいました）
- Google Apps Scriptは、定期的に更新される比較的新しいバージョンのV8を使用。
- V8のバージョンは、安定版のChromeリリースに追跡するかわずかに遅れる傾向。
- 大規模プロジェクトの更新時には、時折遅延や問題が発生することがある。
- GASのユーザーエクスペリエンスはV8だけでなく、エディターの解析機能にも依存。
- 特定の機能がまだ完全にサポートされていないが、GASチームは改善に取り組んでいる。

確かに，ES2022から導入された[Array#at](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/at)や[String#at](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/at)は，2023年12月の時点で，オンラインエディタ上では補完されていないようです．あまつさえ[ClockTriggeerBuilder#at](https://developers.google.com/apps-script/reference/script/clock-trigger-builder#at(Date))と勘違いされてます．

![ 2023-12-20 14.05.25.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636632/6fde8170-204b-57d9-95dc-36a4a5b59e7d.jpeg)


![ 2023-12-20 14.05.42.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636632/ee9099d7-d9cc-b246-191c-a1dbdbe6ce88.jpeg)

しかしそのまま書いてもエラーなく普通に動いてくれます．
![ 2023-12-20 14.06.50.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636632/3890f268-7ae2-b7c1-14fe-0076c793f3e8.jpeg)

こちらのStackoverflowの質問では，ES2021で追加されたNumeric separators（`1_000_000_000`みたいに数値を見やすくするためにアンダースコアをつけれるやつ）がエラーになるぞ，ということでした．
個人的には，オンラインエディタ上でコードを直接触ることはほぼなく，また（大したコードを書いてないというのもあるけど）今までふつうにフロントと同じように書いていても，Apps Script側で「そんな関数・記法なんてないよ〜」なエラーになったことはないので，気にせずに`ESNext`でトランスパイルすることにします．

</div></details>

## gas-client

https://github.com/enuchi/gas-client

Apps ScriptでWebアプリを作ったとき, Apps Script側の関数をフロントから呼ぶには, `fetch`で指定したパスのAPIを叩いて...みたいなおなじみのやつではなく, `script.google.run`関数を介して，グローバルスコープにある関数名を直接指定します．

https://developers.google.com/apps-script/guides/html/reference/run#withsuccesshandlerfunction

`gas-client`はこの`script.google.run`のwrapperです．
今回の環境だとTypeScriptの型推論が動くし，`serverFunctions`を介して関数の補完もしてくれます．超便利！ローカル環境かApps Script環境かを判定してくれるユーティリティ関数も使って書いてました．

Apps Script側でこんなふうにしておいて，
```Main.ts
/*
  Exposed to GAS global function
*/
// シンプルトリガー
global.onOpen = onOpen;
global.doGet = doGet;

// フロント側から呼ばれる関数
global.getWebAppUrl = getWebAppUrl;
global.getSpreadSheetName = getSpreadSheetName;
global.getSpreadSheetUrl = getSpreadSheetUrl;
global.postFormValues = postFormValues;


// Expose to frontend (gas-client)
export {
  getSpreadSheetName,
  getWebAppUrl,
  getSpreadSheetUrl,
  postFormValues,
};

```

```serverFunctions.ts
import { GASClient } from "gas-client";
import { isGASEnvironment } from "gas-client/src/utils/is-gas-environment";
import type * as server from "@/Main";
export const { serverFunctions } = new GASClient<typeof server>();
export { isGASEnvironment };

```

フロントからはこんな感じでimportして使いました．
```getSheetInfo.ts
import { devFetch } from "./devFetch";
import { serverFunctions, isGASEnvironment } from "./serverFunctions";

const SheetNameAPI = async (): Promise<string> => {
  if (isGASEnvironment()) {
    const ret = await serverFunctions.getSpreadSheetName();

    return ret;
  } else {
    return await new Promise((resolve) => {
      setTimeout(async () => {
        // ローカル開発用のダミーデータ
        const res = await devFetch<Record<"title", string>>("/api/title");
        resolve(res.title);
      }, 1500);
    });
  }
};

```

ちゃんとコード補完してくれます。うれしいですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636632/8ebef3c7-a8ba-9abf-e0da-2686a46e11ba.png)


## データ管理
SpreadSheetをDB代わりに使用する，と説明しました．つまり，各シートをテーブルに見立てる感じです．

愚直にカラムから取得し，加工してレスポンスを返します．リクエストデータも同様に愚直に処理して[LockService](https://developers.google.com/apps-script/reference/lock/lock-service)でレースコンディションを回避しつつ[Sheet#appendRow](https://developers.google.com/apps-script/reference/spreadsheet/sheet#appendrowrowcontents)を繰り返します．試してみたところ，今回のような程度ならば[Range#setValues](https://developers.google.com/apps-script/reference/spreadsheet/range#setvaluesvalues)でバルクインサートっぽいことをしなくても，単に`Sheet#appendRow`を繰り返すだけで問題なさそうでした．

またデータの取得ですが，わたしは基本的にセルの値は[Range.getDisplayValues](https://developers.google.com/apps-script/reference/spreadsheet/range#getDisplayValues())として文字列で扱い，アプリ側で好きにすることにしています．アプリによっては表示形式のDateやboolなんかでいろいろ狂いそうですが，「投稿されたデータ自体をいじることはしない」「いじるとしても直接セルを編集する必要があるものはどうせ文字列だけである」という割り切りです．あんまり適切なやり方がわかっておらず，知見もありません．今回の場合，アプリ側でシートから取得するのはいまのところ文字列および数値データのみなので，別段深く考えているわけでもないです．

## テーブル構成
シートはこんな感じです．
- Answer
- Member
- Form Items

編集するのは`Member`と`Form Items`です．Webアプリからはこれらのシートからデータをとってきて，フォームの選択肢の項目になるわけです．

（＊ダミーデータはChatGPTに適当に作らせた架空のものです．）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636632/aec63002-cf9e-8c97-5bf5-a4765815388d.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636632/86ea968a-2465-bdc2-15f5-f837fd7c5d5d.png)

修正したり追加したり削除したりしたいときは，そのまんま特定の行を編集すればOKです．Apps Scriptとの通信が頻繁に発生するようなものだとApps Scriptの制限[^KEY_simulquota]にひっかかりそうだしポーリングやTriggerを使った動的・定期的な更新はしてません。アプリの画面を更新すると変更が反映されます．

今回のようにフロントに返す必要なデータは全部そのまま取得して送るだけ，SQLでいうと`SELECT * FROM ~`な場合，テクニック的なものは思いつきません．せいぜい頻繁に呼ばれる小さなデータを[CacheService](https://developers.google.com/apps-script/reference/properties/properties-service)でキャッシュしておくくらいでしょうか？今回はSheetを直接編集する想定なので，どのくらい頻繁に編集されるか，どのくらいの量が追加されるのか読めず，最新のデータが取得されなかったりCache上限にひっかかって整合性がとれなくなったり，といった想定にまともに対応する方法がわからなかったので，キャッシュではなく毎回Sheetから取得することにしています．今思えばSpreadSheetのタイトルくらいはキャッシュしておいてもよかったかも．

今回は関係ないですが，SQLでいうとWHERE句をApps Scriptで使うフィルタリングが必要な場合のベストプラクティスは本当によくわかりません．いつも[Sheet#getDataRange](https://developers.google.com/apps-script/reference/spreadsheet/sheet#getDataRange())してから[Range#getValues](https://developers.google.com/apps-script/reference/spreadsheet/range#getValues())あるいは[Range#getDisplayValues](https://developers.google.com/apps-script/reference/spreadsheet/range#getdisplayvalues)を使ってテーブル全体を取得してから適宜filterなりmapなり使って構築してるのですが，やはりApps Scriptなので速度がまったく出ません．別で動かしているApps Script Webアプリで，アクセス時にユーザーアカウントを[Session#getActiveUser](https://developers.google.com/apps-script/reference/base/session#getactiveuser)を使って取得し，おおよそ8万レコードのSheetからそのユーザーの行データを加工して返すだけのものがここ半年ほどノーメンテで稼働しており，1,500/日ほど使われてるのですが，Apps Scriptのログ上ではだいたいアクセス時の実行が7秒くらいかかっているみたいです．つまり[FMP](https://developer.mozilla.org/en-US/docs/Glossary/first_meaningful_paint)が最低でも7秒くらいかかってそうです．いい感じに高速化する方法を知りたいです...


## React
あとはフロント側です．SPAをやる気持ちです．フォームは`react-hook-form`で作りました，Google Formsでは実現できなかったあれこれがたくさんできて嬉しいです．たとえば「細かい条件でのバリデーション」「他の項目の値で別の項目を切り替える」「名前の検索が漢字・ひらながどちらでも可能」「`react-window`とインクリメンタルサーチ」「投稿が一度で済む」とか．


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636632/b78f4a16-dd6b-6fdf-d364-2a9e78b9d509.png)
期間の設定では，たとえば20日から25日までお休みとします．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636632/9f526bca-dc04-1305-c579-42a68ea897f2.png)
別の人の投稿も一緒に追加します．

送信すると，複数件でもちゃんと投稿・反映されます．
![Dec-19-2023 16-15-35.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636632/b5b510b8-bc09-9aa9-9258-018aadd075e2.gif)

また，選択肢はインクリメンタルサーチでフィルターできます．それぞれの項目でフィルタリングし，選択肢がただ一つに定まったらその値で設定されます．
![Dec-19-2023 16-20-47.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636632/0756b066-8f1e-d4f7-aab9-7bdbff36bbf8.gif)

などなどありますが，ほぼReactの事情なのでここでは割愛します．Apps Scriptと関係する部分は送受信のコードから雰囲気を感じてください．先ほど紹介した`gas-client`の出番ですね．

```ts:postData.ts
type postDataRequest = string;
type postDataRequestObj = {
  data: FormValues[];
  userId?: string;
};

const postFormValueDataAPI = async (
  data: FormValues[],
  userId?: string
): Promise<postDataResult> => {
  if (isGASEnvironment()) {
    const ret: postDataResult = await serverFunctions.postFormValues(
      JSON.stringify({ data, userId })
    );

    return ret;
  } else {
    return await new Promise((resolve) => {
      setTimeout(() => {
        resolve({ success: true });
      }, 3000);
    });
  }
};

```

```ts:Post.ts
const storeSheet = ss.getSheetByName(StoreSheetName);

type postDataResult =
  | {
      success: true;
    }
  | {
      success: false;
      error: Error;
      message: string;
      data?: any;
    };

const postFormValues = (data: postDataRequest): postDataResult => {
  const requestObj: postDataRequestObj = JSON.parse(data) as postDataRequestObj;
  const formValues: FormValues[] = requestObj.data;
  const userId = requestObj?.userId;
  try {
    // 略　シートの存在チェックやシートのヘッダーが不正なものでないかなど, 正しくデータが追加できるかの前処理が入る

    const TIMESTAMP = new Date();
    for (const formValue of formValues) {
      let days = 1;
      const startDay = parseISO(formValue.registerDate);
      if (formValue.registerEndToDate !== undefined) {
        days += differenceInDays(
          parseISO(formValue.registerEndToDate),
          startDay
        );
      }
      for (let day = 0; day < days; day++) {
        const curDay = addDays(startDay, day);
        const row = [
          TIMESTAMP,
          userId,
          curDay,
          formValue.grade?.value,
          formValue.className?.value,
          formValue.classNumber,
          formValue.name?.value,
          formValue.name?.kana,
          formValue.attendance.value,
          formValue.condition?.map((val) => val.value).join(", "),
          formValue.status,
        ];
        storeSheet.appendRow(row);
      }
    }
    
    return { success: true };
  } catch (err) {
  // 略　エラーハンドリングとか
};

export { postFormValues, type postDataResult };

```
Apps Script側でやっていることは，データを保管するシートの整合性チェックと，フロントから送信されたデータを愚直に追加しているくらいです．前述のとおり，投稿されたデータの扱い（加工やフィルタリング）はシステム側で決め打ちするより編集者が好きなようにいじればいいという思想です．

## カスタムメニュー
管理画面的な機能でも追加してみよっかな〜と思って，途中からGoogle Sheets上にカスタムメニューもつけて遊んでみたのですが，開発環境的に都合がいいなと思いました．アプリ側のhtmlが別でも`vite-plugin-singlefile`のおかげで出力される`.gs`ファイルはひとつだけになるし，カスタムメニュー用の`vite.config.ts`ファイルを作ってまとめてビルド・pushできて楽です．ここではカスタムメニュー用の`menu.html`をバンドルのエントリポイントにしました．

```terminal
$ tree src/customMenu -L 2
src/customMenu
├── client
│   ├── API
│   ├── App.tsx
│   ├── components
│   ├── context
│   ├── main.tsx
│   ├── menu.html
│   ├── Providers.tsx
│   └── routes
└── server
    ├── API
    └── MenuRoot.ts
```
こんな感じのディレクトリで開発し，カスタムメニュー画面専用のvite設定ファイルを使って，ビルドしたファイルはアプリと同じdistフォルダに移動させるようにしました．

```ts:vite.config.menu.ts
import { resolve } from "path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
import tsconfigPaths from "vite-tsconfig-paths";

const root = resolve(__dirname, "src");
const dist = resolve(__dirname, "dist_menu");

export default defineConfig({
  root,
  plugins: [react(), tsconfigPaths(), viteSingleFile()],
  build: {
    outDir: dist,
    rollupOptions: {
      input: {
        menu: resolve(root, "CustomMenu", "client", "menu.html"),
      },
    },
  },
});

```

```package.json
{
  "scripts": {
    "menu-build": "tsc && vite build --emptyOutDir --config vite.config.menu.ts && find dist_menu -type f -name '*.html' -exec mv {} dist/ \\; && rimraf ./dist_menu",
    // 略
```

また，これはやるまで気づきませんでしたが，カスタムメニューからだとWebアプリと違ってデプロイする必要なく画面もApps Scriptの機能も確認できるので，[HtmlService#createHtmlOutputFromFile](https://developers.google.com/apps-script/reference/html/html-service#createHtmlOutputFromFile(String))に渡すhtmlファイルをメインのアプリの`index.html`に切り変えたり新たに確認用のサブメニュー画面を追加したりなどすれば簡易的なテストにも使えるかもしれません．

カスタムメニューの画面では`react-router`を使ってます．実はほとんど作り込んでないので説明を省略します（リポジトリをみてもらうと，ただの中身のないメニュー画面がぽつねんと佇んでおります）

# 苦労したことと展望
ここからは，アプリを作るうえでニャ〜と叫びたくなったことのポエムです．Apps Script以外のことも含みます．

## Reactわからん
わたしはApps Scriptはたまに遊んでるんですが，Reactが全然わからず，ほぼ雰囲気で書いています．書きながら覚えている途中ですが基礎がなってないのを痛感してます．

コードもまずディレクトリ構成がめちゃくちゃです．修正や新規機能に対してとってもアンフレンドリーだと感じます．あと`Context`や`react-hook-form`の`useWatch`フック，カスタムエラーを使ったエラーハンドリングはわりと終盤になって知った機能でして，初歩から書き始めたのでそこかしこに`useState`と`useEffect`が散らばっています．`Suspense`コンポーネントや**エラーバウンダリ**という単語もごく最近になって知り，最初から知っていればデータ取得で悩まなくてよかったしパフォーマンスもマシだったでは...という思いです．わからんといえばアプリ開発におけるテクニックです．ChatGPTさんにダミーデータを1万件くらい作らせて動かしてみたらインクリメンタルサーチが固まってしまい，もうこれは諦めようと思っていたところに`react-window`を知りました．**仮想スクロール** という概念を知らなかったのですがこれもChatGPTさんに教えてもらいました．

react.devを読むところからやってみる所存です．押忍．

## TypeScriptわからん
わたしはApps Scriptはたまに遊んでるんですが，TypeScriptはマジで全然わからず，ほぼ雰囲気で書いています．書きながら覚えている途中ですが基礎がなってないのをいたく痛感してます．

Reactでの利用経験しかないというのもあります．周辺知識のザッピング程度だとやはり他人の書いたコードを読めないです．

とりあえず買ったまま放置しているO'ReillyのTypeScript本をやっつけたいです．


## 展望
やりたいことや，やり残していること，できたらいいな，なもの．[^KEY_mousou]

- 履歴機能
    - 投稿したアカウントからは，自分が何を投稿したかを確認できたほうがいい，という要望があります．確かに，このままだと同じ内容を二重に投稿してしまうことはありそうです．

- カスタムメニューの機能拡充
    - 集計ビューやPDF出力機能なんてのもあったらおもしろいかもしれません．Apps ScriptだとせいぜいDocsとかSlide, Sheetsなんかのテンプレートに差し込んでPDF化という感じになるでしょうが，Reactならそういったライブラリは無限にあることでしょう．グラフ作成もお手の物です．また，各種設定なんかもこれを通じて行えるようにできれば，DBとしてのSpreadSheetを直接編集して破壊，なんてのもある程度防げそうです．
- リファクタリング
    - 今までも言及している通り，現状でもすでに腐臭を感じるひどいコードです．
- 保護者用画面
    - 今回のアプリは学校の先生が個別に投稿するものでしたが，実際は既存の多くのプロダクトやサービスが示しているように，保護者の方が毎朝送信するという場合も多いでしょう．今回のアプリでは，たとえば保護者投稿用モードだとあらかじめ決められた選択肢のリストが出たりするのではなく入力してもらうフォームになり，保護者名を入力する欄が追加され，投稿の際はなりすましを防ぐため学校と保護者間であらかじめ定められたパスワードを入力しないと送信できない，みたいな変動も可能でしょう．パスワードとかはカスタムメニューを通じて[PropertiesService](https://developers.google.com/apps-script/reference/properties/)に保存することになることでしょう．そういえば[Utilities](https://developers.google.com/apps-script/reference/utilities/utilities)のハッシュアルゴリズム系って使ったことなかった．


# リポジトリ
こちらになります．

https://github.com/halllllll/giga_KenkoKansatsu


ここまで読んでいただき，ありがとうございました．

[^KEY_yo]: 要するに「自由にソフトウェアをインストールすることのできないような，そもそも開発環境を整えるのが難しい場合を想定していないよ」です．たとえば今回作成したアプリはVSCodeやDockerを使ってますが，学校現場でそれどうやるのということは考えてません

[^KEY_once_post_every_day]: 「ひと月の間で1日一回ずつだけ投稿できるGoogle Formsを作ってくれ」という依頼で，わざわざGoogle Formsでやらんでもなあ...と思いつつ作ったことがあります

[^KEY_nya]: 「自動化」の名の下に不要なことをやってしまいがちで，たかだか数十件/日ずつしか増えない行の集計を10分ごとのバッチ処理でTriggerに設定したり，ソートするためだけに，誰もみてないのに[Sheet#activate](https://developers.google.com/apps-script/reference/spreadsheet/sheet#activate)しているのを見たことがあります

[^KEY_aaa]: テンプレート用Formsを流用したりブラウザ操作を自動化すればやれないことはないが，そんなこと誰もやるわけがない

[^KEY_kantan]: オンラインエディタに入ってアクセス設定や実行権限を決めてデプロイしてURLを取得するプロセスは説明がダルいと思います

[^KEY_yes]: 別のアプリを作ったとき，他校に簡単に普及できるならやってみてもよいとのことで一度やってみたのですが本当に虚無になったのでした

[^KEY_addon]: Google WorkspaceのAdmin権限があれば組織用の独自アドオンを追加することができるらしいのですが，正直全然わかってないです．誰か教えてください...

[^KEY_simulquota]: 並列実行の上限値（[Simultaneous executions per script or user](https://developers.google.com/apps-script/guides/services/quotas) )は30です．Workspaceアカウントならもっと緩和してくれてもいいのに...

[^KEY_mousou]: 全部妄想です．やるとは言ってません