【個人開発】読書活動がコンテンツになるWebアプリYOMINA（ヨミナ）を作りました

# 序

私は本が好きです。[^1] 
著者、つまり**自分とは別の人の考え方や、その人が考えたことを知ることができる**というのが好きな理由なわけですが、同じ理由で、書評を読むことも同じくらい好きです。
できれば、書評だけではなく、その人がその本を読みながら、どんなことを考えたのかということまで知れたらなお良いです。

- これはある種、野卑な欲求かもしれませんが…[^2]

で、まずは自分からということで、自分が本を読むにあたり、「どの辺を読んでいるときに」、「どんなことを考えたのか」というのを記録して、あわよくばそれ自体がコンテンツになるといいなと思ったので、そういうことができるWebアプリを作りました。

https://yomina.app/

結果、読書タイマーと読書メモと蔵書登録と書籍レビューが一つにまとまったようなサイトができました。

Googleアカウントで使えますので、よかったら試してみてください。

自分の書いた読書記録と書評はXにリンクをシェアしたり、Markdown形式でコピーしたりできるようになっているので、**読書活動のコンテンツ化**にも一役買います。

[^1]: ただし、読書量は並かそれ以下
[^2]: [内面の自由 - Wikipedia](https://ja.wikipedia.org/wiki/%E5%86%85%E9%9D%A2%E3%81%AE%E8%87%AA%E7%94%B1)

## 主な機能
1. 本棚登録
![スクリーンショット 2024-07-29 22.09.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524683/47080744-bc57-dd06-fd5f-5552969577db.png)

    - 各種APIを使って書籍を検索し、本棚に登録します
    - 今読んでる本、読み終わった本以外にも、読みたい本、積んでる本、なども登録できます
1. 読書タイマー
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524683/c7741193-286c-5cee-960b-91a86de44478.png)
    - 本棚から読みたい本を選んで、目標時間を決め、スタート
    - 積算式に時間をカウントします
    - 目標時間を超えたら表示色が変わります。ユーザーにお知らせはしません
        - **読んだら読んだだけ偉い**という思想でこうなってます
1. 読書メモ＆読書活動の記録
    - 読書タイマーの動作中にメモを取れます
    - 読書を終えるタイミングで、今回の読書の感想や総括を入力します
1. 本の評価
    - 本の評価と感想を登録できます
1. シェア
    - 読み終わった本の感想や、日々の読書記録はMarkdown形式でコピーしたり、Xにシェアできます
        - 読書活動そのものがコンテンツになる、ということは、実質的に**インプットがアウトプットになる**わけです
1. 読書を促す通知機能[^3]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524683/aba67dec-6330-e877-d98a-53793b667809.png)
    - 設定画面で決めた時間に、設定画面を操作した端末に対して、WebPush通知が来ます
    - PWAとしてインストールしている時だけ設定できるようにしています
    - 通知機能は十分な負荷検証ができていないので、現在はベータ版の位置づけです
        - 人が増えたとき、Workers FreeのCPU時間10msでいけるのかという問題

[^3]:時間中途半端すぎんか

### ユースケース
想定する使い方はこんな感じ。

:::note info
通知が来たらスマホ（またはPC）でYOMINAを開いて、紙の本や電子書籍リーダーで読書をしつつ、ときどきYOMINAでメモをとる
:::
- 物理本やKindleなど読書端末を使う場合はこの形

:::note info
iPadなどのタブレットをランドスケープモードにし、スプリット機能で片側にYOMINA、片側に電子書籍リーダー、という配置で読書しつつYOMINAでメモを取る
:::
- 電子書籍ならこの使い方がイチオシです。キーボードの配置を変更できるタブレットなら、なおよいです


:::note warn
スマホ1台で、YOMINAをバックグラウンドで動かしつつ電子書籍リーダーを起動して読む
:::
- あまり体験が良くない使い方です
- タイマーが止まるし、メモのために切り替えるのは面倒
  -  「読書したという記録をとる」という目的であれば、読書時間だけ手で入れたらよいので、使えないことはないです

## 参考

参考までに、ここ2週間ほど運用した、私のユーザーページを貼っておきます。

https://yomina.app/user/kedama-t

感想は必須入力にはなってないので、書きたいときに書きたいだけ書くような、気軽な運用にしています。
- メモを取らなきゃ、感想書かなきゃ、というプレッシャーが読書を邪魔してはいけないので

自分で作ったのでそりゃそうなのですが、気に入って便利に使えてます。

## YOMINAの構成要素

単なる宣伝にならないように、ここからは構築基盤とかフレームワークの話をします。

今回の構築にあたり重視したのは、とにかく運用コストを抑えること、具体的には**自分しか使う人がいない**場合でも許容できるコストに抑えることです。[^5][^6]
ここでいうコストというのは金額だけではなく、環境の維持管理にかかる手間も含めてのことです。

[^5]:最低でも1名のユーザーが確保できるという意味で、**自分が使いたいものを作る**ことを意識しています。こうすると、インフラ費用が擬似的にサブスク料金の位置付けになるので、サービスを維持する心理的なハードルが下がると思ってます。
[^6]:この点、個人開発で最低でも２名利用者がいないと成り立たないタイプのサービスを展開している人はすごいなと思います。

- OSの面倒は見たくないのでIaaSは除外
- ネイティブアプリは自分のスキルセット的に厳しいし、開発者登録にお金もかかるので、Web(+PWA)で実現できる範囲で
- [Next.js](https://nextjs.org/)+[Vercel](https://vercel.com/)は商用利用(アフィリエイトリンクがあるため)だと$20/month〜＋別でDBも何か用意しないといけないのでしんどい
- [Firebase](https://firebase.google.com/?hl=ja)+[Remix](https://remix.run/)のSPAモードというのも考えましたが、外部のAPIを叩いたりする関係上、どうしてもBFF的な薄いバックエンドをどこかに構築しなければならず、じゃあCloud FunctionsでとなるとBlazeプランが必須になってしまうのがちょっと…
    - ~~あと、Cloud Functionsのエミュレーターって開発体験いまいちじゃないですか？~~

ということで、少し前に触ってみて開発体験の良かった[Cloudflare Pages](https://www.cloudflare.com/ja-jp/developer-platform/pages/)にRemixのSSRを載せることにしました。

### 思想

YOMINAはUGCをメインコンテンツとするサービス[^7]ですので、正式にリリースした時点で、それなりに長期間サービスを維持する責務が生じます。というか、この手のサービスは、**続きそう**じゃないと誰も使ってくれません。

YOMINAを情報の置き場として[^8]信頼してもらうために必要なことは何か、を自分の視点で考えてみると、

- 長く運用すること
- データにポータビリティを持たせること

この二点が重要になると考えました。少なくとも私が自分で使うサービスを評価するときの基準はそうです。
コストをかけないインフラのチョイスも、一応のマネタイズとしてアフィリエイトを組み込んでいるのも、入力コンテンツをMarkdownで気軽にコピーできるようにしてるのも、この基準を満たすことがモチベーションになっています。

[^7]:私の日記帳として細々と維持される未来も、もちろんありえます。でもそれはそれでいいのです。YOMINAにはポートフォリオとしての側面もあります。

[^8]:アプリとしての手触りが良いとか、品質が高いと感じられる、といった、**使いたいと思うかどうか**の次のハードルとして。

### [Remix](https://remix.run/)
- Reactで開発したかったので、フレームワークはRemixにしました
- Next.jsでも良かったのですが、Remixの[Loader→Component→Actionというデータフロー](https://remix.run/docs/en/main/discussion/data-flow)がわかりやすかったし、何より一度触ってみたかったのでこれにしました
- `loader`は`GET`に、`action`は`POST`(を含むその他のメソッド)に対応するので、コンポーネントを定義せずに、データ登録した後リダイレクトするエンドポイントとか、データ取得だけする共通のエンドポイントとかも作れます
    - YOMINAでいうと、「本棚に登録する」ボタンがその例で、専用のフォームとエンドポイントをセットで定義して、いろんなところから呼べるようにしています

### [Cloudflare](https://www.cloudflare.com/ja-jp/developer-platform/)
- 基本的に全部Cloudflareに乗っかる形にしており、Remixのホスティングは[Pages](https://www.cloudflare.com/ja-jp/developer-platform/pages/)、通知の処理に[Workers](https://www.cloudflare.com/ja-jp/developer-platform/workers/)、DBは[D1](https://www.cloudflare.com/ja-jp/developer-platform/d1/)、画像やSQL結果のキャッシュに[Workers KV](https://www.cloudflare.com/ja-jp/developer-platform/workers-kv/)を使っています
- アバター画像もKVに突っ込んで保管してます
- よほどのことがなければ無料枠に収まる想定でいます
    - 仮に超えても、月$5のPaidプランならお小遣いで何とかなります
- ドメインもCloudFlareで取りました
    - 値段と見た目のバランスから、`*.app`にしました
    - ほんとは`*.io`ドメイン欲しかったけど、値段が…

### 書籍検索
- 書籍検索は、現時点では楽天ブックスAPIと、Google Books APIを利用したものになっています
    - 書籍のデータはYOMINA側でキャッシュし、アクセス時の鮮度に応じて不定期にリフレッシュします
    - 一度YOMINAに登録された本は、YOMINA内検索で検索できます
- AmazonのPA-APIも使いたいのですが、売り上げがないので凍結中…
    - 使えるようになったら実装予定です

### ユーザー認証
- [Remix-Auth](https://remix.run/resources/remix-auth)を使って実装しました
- [コミュニティストラテジー](https://github.com/sergiodxa/remix-auth/discussions/111)から、[`remix-auth-google`](https://github.com/pbteja1998/remix-auth-google)を使っています
- メールアドレス認証は別に要らないかなと思ってオミットしました
  - 「インターネットを使っていて、Googleアカウント持ってない人はごく少数」という認知なのですが、これって歪んでるでしょうか？
  - Googleアカウントはあるけど、**どこの馬の骨とも知れないWebサービスに紐づけたくない**、という心情はわからなくもないので、ちょっと迷いどころですが…

### その他の構成要素
- [Remix-PWA](https://remix-pwa.run/)
    - Web Pushを使いたかったので、PWA化しています
        - ただし、オフラインでは動きません
        - とりあえずタイマーだけはオフラインでも動くようにしようかなと画策中です
- [Bun](https://bun.sh/)
    - 開発環境として
    - 依存関係のインストールが劇的に速くて快適なのでこれを使ってます
- [Drizzle ORM](https://orm.drizzle.team/)
    - ORマッパー。D1との接続はチュートリアル通りにやれば簡単です
- [TailwindCSS](https://tailwindcss.com/)
    - スタイリング
- [Conform](https://conform.guide/) + [Zod](https://github.com/colinhacks/zod)
    - フォームの構築とバリデーション
- [HeadlessUI](https://headlessui.com/)
    - トーストやドロワーのUI構築に[Transition](https://headlessui.com/react/transition)を使用

マスコットのヨミナちゃん[^4]はDALL-E 3に作ってもらいました

[^4]:![ヨミナちゃん](https://yomina.app/images/icon128.png)

### 苦労したところ
割と素直に実装できたと思ってはいるのですが、それでもいくつか苦労した部分があります

#### 時間
- 年明けぐらいから漠然と考えてたので、大体構想2か月、実装は足掛け5か月ぐらいかかってるはずです
    - 隙間時間でやってるにしろ、もうちょっと早く形にしたかったなぁというお気持ち
    - とはいえ、それでもコツコツ実装していたらちゃんと形になるのだからいいものです。これがプログラミングのいいところです
- コミット量からの類推ですが、書いたコードの量は15,000〜20,000行の間ぐらいだと思います
    - GitHub Copilotも活用したので、掛けた時間に対してコード量がだいぶ多い印象です
    - 私は自分が一人で全体を掌握できるコードベースのサイズは10,000～頑張っても15,000行ぐらいだと思っており、YOMINAは結構しんどいコード量になりつつあります

#### ふわふわ設計

- 一応、ざっとワイヤーフレームを**紙で**書いてから作り始めましたが、結果的にあれこれ必要な画面が増えて、当初考えた形より大掛かりなWebアプリになってしまいました
- いったんこの辺でコメントを整備したり、テストをもっと書いてリファクタリングしやすくしたり、というところをやらないとなぁと思っています

#### ファイルベースルーティング

- Remixの[ファイルベースルーティング](https://remix.run/docs/en/main/file-conventions/routes)は結構しんどみが深いです
  - ページ数が増えると`route`フォルダの直下が凄まじいことになります
  - 最初のうちは、命名規則が~~気持ち悪いな~~あまり直感的じゃないなぁと思いながらも、似た位置のルートはファイル名でソートすれば似た位置になるので、そんなに悪くないじゃんと思ってました
  - しかしルートが30を超えたあたりから、とにかく`routes`フォルダが縦長になり、様相が変わってきます
  - なにより辛いのが、VSCodeのエクスプローラーで表示するときこれらが**All or Nothing**になるということです

##### コロケーション（むりやり）
- YOMINAでは各画面のフォームをコロケーションしたかったので、`*.Form.tsx`をルートから除外するように設定して配置しています

```typescript
    remix({
      ignoredRouteFiles: ["**/*.Form.tsx"],
    }),
```

- そのせいで`routes`フォルダがさらにカオスに…
- 参考までに、YOMINAぐらいの規模だと、だいたい**70ファイル程度**がフラットに配置されることになります
    - うち、フォームが16個ぐらいあります
- [remix-flat-routes](https://github.com/kiliman/remix-flat-routes)を使えばかなり整理できそうなので、押しつぶされないうちに導入しようかなと考え中です

#### `action`カオスになりがち問題
- Remixは一つの画面で複数の処理をしたい場合、`action`がカオスになりがちです
    - `*.Form.tsx`から、zodのスキーマ、`action`ハンドラー、`Form`コンポーネントをexportする
    - `Form`コンポーネントの`<input type="submit" />`は属性として`name="action"`と`value="<アクション名>"`を持つ
- というルールにして、ルート側からは`submit`の`value`で分岐して、該当するフォームの`action`を呼ぶように実装しました
    - これ、何かもっといい方法があったら教えてほしいです

```typescript:こんなイメージ
const action = formData.get("action");

if (action === "delete") {
     return await deleteAction(formData, args.context.cloudflare.env);
} else if (action === "edit") {
     return await editAction(formData, args.context.cloudflare.env);
}
```

YOMINAで言うと、ユーザー側の「読書記録」の単件表示の画面(`/logs/$logId.tsx`)は、読書記録本体の`編集`・`削除` ＋ 読書メモの`追加`・`編集`・`削除`の計5アクションが犇めく魔境と化しました。
あまり詰め込みたくはなかったんですが、読書メモの単件画面は労力的にもUX的にも作りたくなかったので…。

#### `https`使えない問題
- Cloudflare Workersはnodeじゃないので、`https`を使うパッケージが動かないようです
    - 通知のためのWorkerを別で一つ動かしているのですが、`web-push`も`@remix-pwa/push`もダメだったので、[いろいろ探した結果](https://github.com/web-push-libs/web-push/issues/718)、[webpush-webcrypto](https://github.com/alastaircoote/webpush-webcrypto)を使って実装したら動きました

#### サボるタイマー問題
- `setInterval`はタブが後ろに隠れたりするとサボってちゃんとカウントしなくなるので、タイマーは[Web Worker](https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Using_web_workers)として実装しています

#### ちらつくダークモード問題
- `localStorage`に設定を保存しているのですが、初回表示やリロードがちらつきます。なんとかしたい
    - ダークモードって、ちゃんと実装するの難しいですね…

#### D1+Drizzle ORMにおける`join`の挙動問題
- [こ](https://github.com/drizzle-team/drizzle-orm/issues/555)[れ](https://github.com/cloudflare/workers-sdk/issues/3160)を踏みました
    - `id`は全テーブルにあるので、`join`があるところであまねく発生します。つらみ

#### 外部APIのレートリミット
- [Rate Limiting](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/)が使えると思ってたのですが、これは、Pagesだと使えません(2024/7現在)
- 仕方ないので、KVにAPIごとの前回実行時刻を持つようにして、レートリミットを掛けています
- KVは、[Writeがグローバルに伝播するまで最大60秒ほどかかる](https://developers.cloudflare.com/kv/api/write-key-value-pairs/#parameters)のが若干の不安要素ですが、基本的には日本でしか使われない想定なので、多分大きな問題はないはず…
    - 楽天APIを結構安直に呼んでしまう仕組みにしてしまったのですが、ユーザー増えたら厳しいかも、という気がしています…
        - 楽天API呼びすぎて止められてしまうとサービスに本を登録する導線が死ぬので、レートリミットは順守しなければならない
    - 今後、単なるレートリミットではなく、キューイングの仕組みもいるかもしれないです
- KVでの結果キャッシュなども組み込んだ結果、だいぶ検索がもっさりになってしまった感があります。何かいい方法がないか模索中です… 

#### 読書セッションのレジューム
- 読書タイマーの動作中にブラウザを閉じたりしてもいいように、読書セッションのレジューム機能を実装しています
- Remixの`useBeforeUnload`を使って、`localStorage`に保存して、再表示時に読みだす、という形で、一応頑張って読書セッションを維持しようとします
    - つまり別の端末/ブラウザで開いたらダメ
- クライアントサイドで全部処理してるので、結構頑張って実装した割に、普通に切れます。悲しい
    - グローバルなステート管理であり、挙動も複雑なので、あんまりうまくいっている感じがしません
    - 本気で実装するならセッション管理はサーバー側に移して、ServiceWorkerと連携させるなどする必要がありますが、結構作業量がありそうなので躊躇しています…

### これから

現時点で未実装ですが、先述の通り、データのポータビリティは重視したいと思ってるので、本棚と読書記録のエクスポート機能をおいおい実装する予定です。

また、評価軸を「面白かったか」と「人に薦めたいか」の二軸に分けてるので、**自分と同じような趣味の人がおすすめしている本**をパッと検索できるようにしたいです。これがこのアプリを作った最大の目的といっても過言ではありません。ユーザーがたくさんいてくれてこその機能だと思うので、使う人が増えてくれると嬉しいなあと思います。

リコメンド機能や、メモのとりまとめ機能として生成AIを組み込むのもよいかもしれません。

あと、個人的に悩ましく思っているのがISBNコードのない雑誌や電子のみの書籍、Zenn本などの扱いで、これらは現在YOMINAでは扱えません。例えば、[Software Design](https://gihyo.jp/magazine/SD)なんかは勉強の記録を取りたかったりするので、何とか登録できるようにしたいなと思っています。

UdemyやYouTubeなどの動画や、PodCastをインプットにしてる人も多いと思うので、この辺を全部抽象化して、最終的には**インプット支援プラットフォーム**というところまでいけるといいなと。

### 終わりに
壮大な野望を語ったところで、記事はこのぐらいにしたいと思います。

重ねての記載になりますが、Googleのアカウントがあればすぐに使えますので、ぜひお試しいただけると嬉しいです。

https://yomina.app/