ワダチを作る #6:習慣と内容にアイコンを付けて、削除もできるようにした
前回(#5)で、タイマーを本番に出して、アプリのアイコンを入れました。今回は、習慣ごとのアイコンと、習慣の削除です。
- 習慣にアイコンの画像を登録して、記録の画面・一覧・編集の画面に出す
- 朝活のように下にぶら下がる内容にも、アイコンを付ける
- 習慣を、記録ごと削除できるようにする
作業は、いつもどおり「Issue → ブランチ → PR」です。AC(受入条件)に沿って、テストを書きました。
アイコンの保存先:D1 にした
習慣ごとに、画像(アイコン)を1枚、登録できるようにします。いちばん最初に考えたのは、画像を、どこに置くかです。
- R2(Cloudflare のオブジェクトストレージ):ハコヅミが、すでに使っています。画像を置くなら、定番です
- D1(いま、習慣や記録を入れているデータベース):画像を、小さなデータ(BLOB)として入れる
ワダチのアイコンは、128px に縮めた WebP で、1枚が 5〜8KBです。この大きさなら、D1 に入れても、ほとんど場所を取りません。D1 に入れると、いいことがあります。
- 習慣を消すと、アイコンも、一緒に消える(
ON DELETE CASCADE) - D1 を過去の時点に戻す手順(
docs/restore.md)で、アイコンも、一緒に戻る - R2 のバケットを、開発用・本番用の2つのアカウントに作る、という手間がない
R2 は、背景や動画を、管理者が用意する段階で、足すことにしました。動画は大きいので、D1 には入れられません。そのときに、R2 を足します(iOS の Safari は、動画を途中から読む Range で取りに来るので、配信する側の対応が要る、という注意も、メモしました)。
縮めるのは、ブラウザ
選んだ画像は、ブラウザの中で、長い辺が 128px に収まるように縮めてから、送ります(透過は保つ。Safari などは WebP にできないので、PNG)。大きな画像(数 MB の PNG)を選んでも、送るのは、数十KB以下です。
サーバーは、送られた画像を、ファイルの先頭のバイトで、PNG・JPEG・WebP のどれかを見分けます。送られてきた「種類の申告」は、信じません。SVG は、スクリプトを埋め込めて、XSS のもとになるので、受けません。大きさは、50KB までです。
Drizzle の blob は、Workers で使えなかった
D1 に画像を入れるテーブル habit_icons を作るとき、Drizzle の blob(mode: 'buffer')を使うつもりでした。ところが、これは、Node の Buffer を使います。Cloudflare Workers には、Buffer がありません(nodejs_compat を使っていないため)。
customType で、Uint8Array として読み書きする型を、自分で作りました。D1 が、BLOB を ArrayBuffer か、数の配列で返すので、どちらも受けるようにしています。ローカルの D1(workerd)で、書いたバイト列が、そのまま読み出せることを、E2E で確かめました。
渡された画像に、市松模様が焼き込まれていた
アイコンは、ChatGPT で作ってもらいました(早起き・坐禅・朝活・夕活・読書・ロードバイク・筋トレ・勉強・プログラミングの9つ。茶色の絵柄)。ところが、「背景は透過」と頼んだのに、届いた画像は、白と灰色の市松模様が、絵として焼き込まれていました。透過ではなかったのです。
そこで、画像の外側だけを塗りつぶして、透過にして、絵ごとに切り出しました。
- 市松模様の灰色から、近い部分だけを、背景とみなして、四辺からの塗りつぶしで、外側を消す
- 絵の中の白(マグカップ・本・雲)は、市松模様の灰色から遠いので、残る
- 8つの絵が1枚に並んでいたので、近い部分どうしをまとめて、1つずつに分ける

切り出したアイコン(明るい背景に置いて、確認したもの)
絵の上のほうの、ぼんやり消えていく空は、市松模様と、見分けられなかったので、少し削れました。画面の大きさ(32〜64px)では、気にならない程度です。
これらの画像は、リポジトリには入れていません。利用者が、自分で、登録のときに選ぶものなので、~/Downloads に置きました。
内容にも、アイコンを付けた
朝活のように、下に「内容」(個人開発・勉強・ロードバイクなど)がぶら下がる習慣があります。この内容ごとにも、アイコンを付けられるようにしました(habit_option_icons)。
習慣のアイコンと、作りは同じです(縮小・検査・D1 に入れる)。ただし、テーブルは分けました。持ち主の決まり方が、違うためです。習慣は、user_id で直接決まります。内容は、「内容 → 習慣 → user_id」と、たどって決まります。

記録の画面の「やった内容」に、内容のアイコンが出る(架空のデータ)
見てみたら、小さかった
記録の画面に、アイコンを出してみると、32px では、小さすぎて、絵がつぶれて見えました。
- 記録の画面を、64px にして、タイマーの「▶」を、枠の下の段から、枠の右端に移した
- 習慣の一覧にも、同じ 64px で出した
- 編集の画面の、内容のアイコンも、習慣のアイコンと同じ 64px にした
受入で見て、直したところです。最初に作った画面を、実際の画面で見ることで、はじめて、「小さい」と分かりました。
ファイル選択が、どこを押すのか、分からなかった
登録の画面の「アイコン」の欄が、ブラウザ標準のファイル選択のままで、白い背景に文字だけ。どこを押せばよいのか、分かりません。ハコヅミの画像の欄と同じように、枠と背景を付け、「ファイルを選択」の部分を、基本色の、ボタンの見た目にしました。

登録の画面。選ぶとプレビューが出る(ChatGPT の画像は、ブラウザで縮められる)
習慣を、記録ごと削除できるようにした
ここまでは、習慣は「やめる」と「再開」しか、できませんでした。登録を間違えた習慣が、一覧に残り続けます。
そこで、削除を作りました。ただ、企画では、「やめた習慣も、過去の記録は残る」、つまり、積み上がった跡は消えない、という原則がありました。削除は、その原則と、ぶつかります。次の3案を、比べました。
- 記録のない習慣だけ、削除できる
- 記録があっても、削除できる(確認を厚くする)
- 「やめた習慣」を、一覧で折りたたむ
2に決めました。 「消す」ことは、利用者が、意図してするものです。その代わり、確認を、厚くしました。
- 「やめる」とは別の操作にして、編集の画面の一番下に置く
- 押すと、確認のダイアログ(ブラウザ標準の
confirmは使わず、ハコヅミのConfirmDialogを移した) - 記録がある習慣は、習慣の名前を入力するまで、「削除する」を押せない(取り違えを防ぐ)
- サーバーも、同じ判定で断る(名前が合わなければ 400)
削除すると、期間・内容・記録・その日に選んだ内容・タイマーの記録・アイコンが、すべて、一緒に消えます(外部キーの ON DELETE CASCADE)。テーブルは、変えていません。E2E で、削除の前後で、7つのテーブルの行数を比べて、すべて 0 になることを、確かめました。
本番リリースと、うっかり
ここまでを、本番に出しました(マイグレーション 2 つ:habit_icons・habit_option_icons)。今回も、PR の本文に、「戻す先の時刻(UTC)」を書いて、マージのあとで、GitHub のマージの時刻に書き換えました。
うっかりが、1つありました。マージ済みの PR に、あとから、コミットを足して push してしまい、新しいアイコンが、本番に入りませんでした。 マージ済みの PR には、新しいコミットは入りません。これを、2回、やりました。
直し方は、同じです。コミットを足す前に、PR の状態を確かめる。マージ済みなら、新しい PR にする。
おわりに
- アイコンの保存先は、D1(R2 は、背景・動画の段階で)
- 縮めるのは、ブラウザ。サーバーは、先頭のバイトで、種類を確かめる
- 内容のアイコンは、習慣のアイコンと同じ作りで、テーブルは分ける
- 削除は、記録ごと。名前の入力で、取り違えを防ぐ
内容ごとのタイマー(朝活の「勉強」を、30分のタイマーで、など)も、話に出ました。今は、タイマーは、習慣ごとの設定です。時間を使いたい内容と、使い方を決めてから、作ることにして、いったん、保留にしました。
次回は、画面の見た目(ヘッダー・バナー・日付の帯・基本色)を、整えた話です。