ワダチを作る #3:習慣の登録と毎日の記録を作って、最初の機能を本番に出した
前回(#2)で、雛形・CI・デプロイを本番まで通しました。今回は、ワダチの最初の機能です。
- 利用者を決める基盤(ログインがない間の仕組み)
- 習慣を登録・編集・やめる・再開する
- 毎日の記録を付ける
- 最初の機能のリリース(マイグレーションが入る、はじめてのリリース)
作業は、これまでどおり「Issue → ブランチ → PR → マージ」です。最初の機能は、3つの Issue(基盤 → 登録 → 記録)に分けました。1つにすると、PR が大きすぎて、読んで理解するのがむずかしくなるためです。
先にステップ1を閉じて、ステップ2(基本の機能)に進んだ
土台づくりは、マイルストーンの「ステップ1」にまとめていました。雛形・CI・デプロイ・CLAUDE.md・スキル、そして本番まで通したところで、ステップ1を閉じました。
最初は、他のステップがまだ決まっていないので、「今の段階の作業は、すべてステップ1に入れる」と CLAUDE.md に書いていました。それを、「ステップ1は終えた。今はステップ2(基本の機能)」に書き直しました。ステップ2は、Notion の企画の「基本機能」(習慣の登録、毎日の記録、積み上がりの見せ方、坐禅のタイマー、Notion からの取り込み)にあたります。
ログインがないのに、「最初からマルチユーザー」を守る
最初の機能の前に、決めることがありました。利用者を、どう決めるかです。
ワダチは、「最初からマルチユーザーで作る」と決めています。ハコヅミは、user_id を固定の値(owner)で始めて、ログインを入れるときに付け替えるマイグレーションが要りました。同じことを繰り返したくありません。
ただ、ワダチには、まだログインがありません。ハコヅミのログイン(Better Auth)の切り出しは、範囲が大きく、別の仕事にしています。
そこで、次の形にしました。
- 開発環境も本番も、Cloudflare Access の後ろにある。Access は、ログインした人のメールアドレスを、リクエストのヘッダーで渡してくれる
- そのメールアドレスで
usersの行を探し、なければ作る。user_idは、その行のid(ランダムな値)。メールアドレスそのものは、持ち主の印に使わない(変わりうるため) usersは、ハコヅミの Better Auth のusersと同じ形の列にした。あとで Better Auth を入れるとき、同じテーブルをそのまま使えて、idの付け替えが要らない
注意点が2つあります。
1つ目:手元の開発サーバーには、Access がない。 代わりに、.dev.vars の DEV_USER_EMAIL を使います。ただ、デプロイした環境に、うっかり値が入っていたら、誰でもなりすませます。そこで、ホスト名が localhost・127.0.0.1 のときだけ効くようにしました。本番の URL、workers.dev、Preview URL では、値があっても使いません。これは、ユニットテストで確かめています。
2つ目:ヘッダーの値は、Access を通らずに直接アクセスできれば、偽れる。 今は、Access の後ろにあることが前提です。そのため、公開して Access を外す前に、ログインを入れることを、CLAUDE.md に書きました。
同時に初めてアクセスしても、users の行が1つだけになることも、E2E で確かめています(行を作るとき、on conflict do nothing のあとに読み直す)。
習慣の登録:期間の「やめた日」は、その日を含む
習慣のテーブルは3つです。
habits:名前、時間帯(朝・夜)、頻度、並び順habit_periods:始めた日・やめた日。休止と再開で、複数持てるhabit_options:内容の選択肢(朝活の「個人開発」など)。1件もない習慣が、固定の習慣
固定の習慣と朝活を、別の種類にせず、「選択肢を持つかどうか」だけの違いにした話は、#1で書いたとおりです。それを、そのままテーブルにしました。種別の列は、ありません。
頻度は、毎日・曜日を指定(複数)・週○回の3つです。画面には、「平日(月〜金)」「月・水・金」「週3回」のように出ます。

習慣の一覧。時間帯ごとにまとまり、頻度が名前の下に出る(架空のデータ)

登録の画面。頻度は、毎日・曜日を指定・週○回から選ぶ
ここで、1つ決めることがありました。期間の「やめた日」を、どう数えるかです。
- A:やめた日の翌日から行わない(やめた日は、含まない)
- B:やめた日を含む最後の日(その日までは行っている)
Bにしました。 やめるボタンを押した日に、その日の分をもう記録していることがあります。Aだと、その記録が、「期間の外」になってしまいます。Bなら、やめた当日の記録も数えられます。画面には、「2026-10-21 〜 2026-10-31」のように出ます。
期間の判定は、画面とデータベースから切り出して、ユニットテストできる形にしました(今、行っている日か、やめる日、再開できるか)。再開できる日は、前の期間のやめた日より後です。期間が重ならないようにするためです。

休止と再開で、期間が複数になる。冬だけの習慣の例(架空のデータ)
毎日の記録:「やった」だけを持つ
記録の画面は、トップページです。その日の習慣を、時間帯ごとにまとめて出し、チェックを付けると、その場で保存されます。朝活のような習慣は、「やった」を付けると、内容が出て、複数選べます。

記録の画面。タップすると、その場で保存される。朝活は、チェックすると内容が出る(架空のデータ)
記録のテーブルは、habit_logs(習慣・日付。同じ習慣・同じ日は1行)と、habit_log_options(その日に選んだ内容)です。
持つのは、「やった」だけです。「やらなかった」の行は、持ちません。行があれば「やった」、なければ、やっていない。こうすると、「やらなかった」と「まだ付けていない」を区別しなくて済み、積み上がりの数え方も、単純になります(連続日数を主役にしない、という方針とも合います)。
出さない日・付けられない日
次の日は、一覧に出さず、サーバーも断ります。
- 期間の外の日(始める前、やめたあと)
- やらない曜日(曜日を指定した習慣)
- 今日より先の日
週○回の習慣は、週のうちのどの日でもよいので、どの日でも付けられます。
先の日を付けられないのは、「前の日に戻って付ける」ことが目的で、先に付ける理由がないためです。画面の ?date= に先の日を書いても、今日の画面になります。

前の日に戻って付ける。「今日へ」で戻れる。今日には「次の日」がない
隠した内容は、新しく選べない
内容の選択肢は、「隠す」ことができます。隠した内容は、新しく選べません。ただし、すでに選んである分は、外せるように残します。隠した内容が選ばれたまま、外せなくなっては困るためです。
この判定も、切り出して、テストしています。

内容の選択肢の画面。隠した内容は、取り消し線で出て、「戻す」で戻せる
「その日」は、利用者のタイムゾーンで決める
「今日」を、どこの時刻で決めるか。サーバーの標準時(UTC)では、日本時間の朝6時は、前の日になってしまいます。そこで、ブラウザのタイムゾーンを、クッキーに覚えておき、サーバーが読む形にしました。クッキーがまだないあいだは、日本時間で決めます。
初めてアクセスしたときは、クッキーがないので、日本時間で画面が出ます。その後、ブラウザがタイムゾーンを覚えたら、画面を読み直します。日本時間とずれる人でも、2回目からは、正しい日付になります。
日付の境界のテスト
日付の判定は、まちがえやすいところです。次を、ユニットテストにしました。
- 日付が変わる直前・直後(日本時間の 2026-10-21 00:00 は、UTC では 10-20 15:00)
- タイムゾーンごとの日付
- うるう日(2028-02-29)、月またぎ、年またぎ
- 曜日(月曜 = 1 〜 日曜 = 7)
もう1つ、E2E で、UTC+14 と UTC−11 の2人を使いました。この2つは25時間ちがうので、同じ瞬間でも、日付は必ずちがいます。それぞれの見出しの日付が、それぞれのタイムゾーンの今日になることを、確かめました。サーバーの現在時刻は、テストから動かせないので、この形にしました。
幅のテスト:はみ出しを検出できるかも確かめた
画面は、スマホ(375px)・iPad mini(768px)・パソコン(1280px)で、横にはみ出さないことを、E2E で計りました。長い名前(50文字の英字など)を入れて、横のはみ出しが 0 であることを確かめます。

iPad mini の幅。同じ画面を、広さに合わせて使う
ここで、**「このテストは、本当にはみ出しを検出できるのか」**が気になりました。はみ出していないから通るのか、そもそも計れていないから通るのかが、区別できないためです。そこで、使い捨てのテストで、折り返しの設定を切って、はみ出しが 176px と検出されることを確かめました。計れていることが分かったので、安心して、残しました。
CI で見つかった、E2E の2つの不安定さ
PR を出したら、CI の E2E が1件、落ちました。手元では、通っていました。原因は2つでした。
1つ目:リンクの href が、読み込みの途中は、相対の形だった。 テストが /habits/… を期待して読んだ値が、./habits/… でした。画面を読み込む途中は相対の形で、読み込み終わると /habits/… になります。手元は読み込みが速いので、気づきませんでした。CI は遅いので、途中の形を読んでいました。テストで、いまのページを基準にして、パスに直すようにしました。
2つ目:アプリの読み取りが、SQLITE_BUSY で 500 になっていた。 アプリ(workerd)とテスト(node:sqlite)が、同じ SQLite のファイルを使うので、たまたま重なると、アプリの読み取りが失敗します。そのテストは通っていましたが、放っておくと、別のテストが不安定になります。利用者を作る補助で、最初の読み込みが失敗したら、少し待ってやり直すようにしました。アプリの不具合ではありません。
どちらも、CI のログを読んで、原因が分かったものでした。直したあとは、手元で全件を2回続けて流して、通ることを確かめました。
最初の機能のリリース:マイグレーションが、はじめて本番に入った
ステップ2の3つの Issue が、開発環境で受入を済ませたので、本番へのリリース(develop → main)をしました。前回(#2)の雛形のリリースに続く、2回目です。
今回は、マイグレーションが入る、はじめてのリリースです(前回は、テーブルがなかった)。本番の D1 に、users、習慣の3つのテーブル、記録の2つのテーブルができます(どれも、テーブルを作るだけで、既存のものを変えない・消さない)。
そのため、リリースの PR の本文に、「戻す先の時刻(UTC)」を書きました。壊れたときに、その時刻へ、D1 を戻せるようにするためです。PR を作った時刻を書き、マージのあとで、GitHub のマージの時刻に書き換えます。マイグレーションが当たるのは、マージのあとのデプロイの中なので、マージの時刻は「当たる前」を指します。
本番のデプロイは成功し、3つのマイグレーションが、順に適用されました。本番の URL は、Access の後ろなので、ブラウザで開いて、画面を確かめました。
戻す手順を、あとから書いた
リリースの PR には、「戻す手順は docs/restore.md」と書きました。ところが、ワダチには、まだこのファイルがありませんでした(ハコヅミにはあります)。時刻だけがあって、使い方がない状態でした。
リリースのあとで、手順書を書きました。ハコヅミのものを土台に、退会した人の消し直し・画像(R2)・KV の話は外しました。ワダチには、まだないためです。まだ通して試していないので、「試した記録」の表は空にして、Notion からの取り込みの前に、開発環境で試すことにしました。
小さな反省:どのアカウントか
D1 の中身を確かめようとして、手元の wrangler が、どのアカウントにログインしているか、まちがえました。メールアドレス(jiru.labo@gmail.com)が出ていたので、本番用だと決めつけたのですが、実際は、アカウント名が「じる」(開発用)でした。
ハコヅミの CLAUDE.md に、「どちらのアカウントかは、メールアドレスではなく、アカウントで見分ける」と書いてあるのに、守っていませんでした。確かめかたは、wrangler d1 list に出る D1 の ID が、wrangler.jsonc のものと同じかどうかです。
おわりに
最初の機能で、決めたことを並べます。
- 利用者は、Access のメールで決める。
usersは、Better Auth と同じ形にして、付け替えをなくす - 期間の「やめた日」は、その日を含む
- 記録は「やった」だけを持つ
- 「その日」は、利用者のタイムゾーンで決める
判定を、画面とデータベースから切り出して、ユニットテストにしておいたので、日付の境界のような、まちがえやすいところを、安心して直せました。
次は、坐禅のタイマーの試作です。Notion の企画で、「最初に iPad mini の実機で確かめる」と決めていたものです。