習慣トラッカーのストリーク設計で心が折れないDB・通知の作り方
自分でスクラッチした習慣トラッカーで、37日目に記録を1日忘れて連続日数が0に戻り、そのままアプリを開かなくなったことがある。C言語のプロジェクトで昔よく作っていた「日次バッチのログ管理」の延長でDB設計をしたのが、そもそもの間違いだった。
この記事は、自分で習慣管理系のツールを作ろうとしているエンジニア、あるいは既存アプリのストリークが切れて心が折れた経験がある人向けに書いている。趣味プロジェクトでもいい、副業のダイエット記録アプリでもいい。「連続記録」というUIを甘く見ると、作った本人が一番先に脱落するという話をする。
結論を先に書く
ストリーク(連続日数)をDBの主キーやトリガーの起点にすると、1日抜けた瞬間に数値がリセットされ、ユーザーの心理も同時にリセットされる。これを避けるには、連続日数を「都度計算するビュー」に留め、実データは「実施ログの積み上げ」として持つ設計にする。通知も「今日やれ」ではなく「今週どれだけ積み上がったか」を軸にする。この2点を変えるだけで、離脱率の体感がかなり変わった。自分の場合、90日運用してストリーク切れによる離脱が0件になった(それ以前は3週間で2回、自分自身が離脱していた)。
なぜこの問題が起きるか
習慣トラッカーを自作しようとすると、最初にやりがちな設計はstreak_countというカラムをテーブルに直接持たせて、1日サボったら0に戻すロジックを書くことだ。これは実装が楽なので、多くの個人開発者がここから入る。自分もそうだった。
だが、この設計には構造的な問題がある。streak_countという単一の数値に、ユーザーの「頑張ってきた日数」が全部乗っている。1日抜けただけでその数値がゼロになると、DB上は「正しい」動作をしているのに、ユーザー体験としては「積み上げてきた全部が消えた」という感覚を生む。
Habitica系のアプリでこの現象に苦しめられた経験がある人は多いと思う。自分は3年前、あるダイエットアプリで62日目にストリークが切れて、そこから2ヶ月アプリを開かなかった。開発者目線で見ると「たかがUIの表示ロジック」なのに、ユーザー目線では「積み上げが消滅した」に見える。この非対称性が問題の本質だと考えている。
さらに厄介なのが、通知設計との組み合わせだ。「今日の記録がまだです」という通知は、ストリークが継続している間はモチベーションになるが、切れた後は「もう手遅れなのに催促される」通知に変わる。これがアンインストールの引き金になっていた。自分が過去に作った初代の習慣アプリでは、通知経由の起動率が1週目87%から4週目23%まで落ちた。原因を追うと、ストリークが切れたユーザーへの通知文言をサボり続けていない人と同じにしていたことが大きかった。
解決手順
ステップ1:ストリークを保存せず、都度計算するビューにする
まずやるべきは、streak_countのような累積カラムをテーブルから消すことだ。実施ログ(logsテーブル、user_id, logged_at, action_type程度のシンプルな構成)だけを正として持ち、「今の連続日数」は都度SQLで算出する。
これによって、過去のログを修正・追加したときに整合性が壊れる心配がなくなる。自分は初期実装でストリークをキャッシュ的に保存していたせいで、バックデート入力(前日分の記録を後から追加する機能)を実装したときにストリーク数がズレるバグを2回踏んだ。都度計算に変えてからはこの手のバグが出ていない。
ステップ2:「連続日数」ではなく「実施率」を主指標にする
ストリークの代わりに、「直近7日中5日実施」「直近30日中22日実施」のような実施率を主要な指標にする。これは心理的に効くポイントで、1日抜けても数値が急落しない。62日→0日という崖ではなく、87%→85%という緩やかな下降になる。
自分がドッグフーディングしているツールでは、この実施率をベースに「今週の調子」というラベルを出すようにしている。数字そのものより、下がり方が緩やかであることの効果が大きいという実感がある。
ステップ3:通知はストリーク依存をやめ、行動データ依存にする
通知ロジックを「前日記録したかどうか」で分岐させるのをやめて、「直近3日の実施率」を見て文言を変える設計にする。実施率が高い人には「順調です」、低い人には「1回だけ記録してみませんか」くらいの軽い文言にする。
C言語で組み込み向けのバッチ処理を書いていた頃の感覚だと、これは「エラー発生時に即座にアラートを鳴らす」設計から「閾値を超えたら段階的に警告レベルを上げる」設計への変更に近い。閾値設計の考え方はそのまま転用できる。
ステップ4:リセットのタイミングを「日次バッチ」ではなく「猶予期間つき」にする
日付が変わった瞬間にストリークが切れる設計だと、夜型の生活をしている人や、タイムゾーンをまたいで生活している人が理不尽に損をする。自分は当初、UTC基準で日次バッチを回していたせいで、深夜0時〜2時の間に記録した分が前日扱いになるバグを1ヶ月放置していた。ユーザーからの問い合わせで気づいた。
猶予期間(例えば「実施日の翌日の正午まではセーフ」)を設けることで、この手の理不尽な失敗を減らせる。
実例:実施率算出のSQLとC言語での集計バッチ
実施率をSQLで都度計算する例(PostgreSQL想定)。
SELECT
user_id,
COUNT(DISTINCT DATE(logged_at)) AS active_days,
30 AS window_days,
ROUND(COUNT(DISTINCT DATE(logged_at))::numeric / 30 * 100, 1) AS rate_percent
FROM logs
WHERE logged_at >= NOW() - INTERVAL '30 days'
AND user_id = $1
GROUP BY user_id;
このクエリはlogsテーブルにインデックス(user_id, logged_at)を張っておかないと、ユーザー数が増えたときに遅くなる。自分は当初これを忘れていて、ユーザー数が200人を超えたあたりでダッシュボード表示に1.2秒かかるようになった。
組み込み系の集計バッチをC言語で書く場合、日次のログファイルから実施日をユニークカウントする処理は以下のような構造になる。
#include <stdio.h>
#include <string.h>
#define MAX_DAYS 30
#define DATE_LEN 11
int count_unique_days(char dates[][DATE_LEN], int total_logs) {
char seen[MAX_DAYS][DATE_LEN];
int seen_count = 0;
for (int i = 0; i < total_logs; i++) {
int is_new = 1;
for (int j = 0; j < seen_count; j++) {
if (strcmp(dates[i], seen[j]) == 0) {
is_new = 0;
break;
}
}
if (is_new && seen_count < MAX_DAYS) {
strcpy(seen[seen_count], dates[i]);
seen_count++;
}
}
return seen_count;
}
組み込み向けにメモリを固定長で確保しているが、実運用ではログ件数の上限をきちんと見積もっておく必要がある。自分はここでMAX_DAYSを30日固定にしたまま、90日レポート機能を後から追加してバッファオーバーフローに近い挙動を起こしたことがある。配列サイズの前提を変える機能追加をするときは、関連する定数を全部洗い出す作業を先にやったほうがいい。
落とし穴と対処
ストリーク設計を都度計算に変えても、UIの見せ方を変え忘れると意味がない。自分は一度、バックエンドの実施率計算は直したのに、フロントの画面では相変わらず「連続◯日」という表示を残してしまい、結局ユーザーの心理的な崖はそのままだった。DBとUIは両方セットで直す必要がある。
もう一つの落とし穴は、実施率の計算窓(7日、30日など)を途中で変更すると、過去との比較ができなくなる点だ。自分は最初7日窓で作っていたが、途中で30日窓に変えたところ、既存ユーザーから「急に数字が下がった」という問い合わせが3件来た。窓の変更は基本的に新規ユーザーにだけ適用するか、両方の数値を一定期間併記するかのどちらかにしたほうがいい。
通知文言についても、実施率に応じて何段階か用意したはずが、境界値のテストを怠っていて、実施率50%ちょうどのユーザーに「順調です」と「1回だけ記録してみませんか」の両方が送られてしまったことがある。境界値は必ず単体テストに含めるべきだと痛感した。
関連ツール
このストリーク設計の問題は、習慣管理だけでなく「副業の進捗」と「ダイエットの記録」を同時に扱おうとするとさらに複雑になる。片方は毎日、片方は週次でしか動かないことが普通にあるからだ。自分はこの課題に対して、複数の指標を1つのキャラクターの成長として扱うツール(ALTER)を作って自分で使っている。副業のcommitと歩数を別々のアプリで管理して両方続かなかった経験がある人には、設計思想として参考になるかもしれない。
→ ALTER
まとめ
ストリークをDBの主キーやトリガーにすると、1日の抜けがユーザー体験としては積み上げ全部の崩壊に見える。実施ログを正として持ち、連続日数は都度計算するビューにとどめ、通知も実施率ベースに変える。この3点を直しただけで、自分の場合はストリーク切れによる離脱が90日間0件になった。作ってから気づく設計ミスは多いが、通知とDBはセットで見直す価値があると考えている。
著者:ぽん(@pon_freelance)
C言語実務23年、組み込み/制御系。
副業で技術記事販売と自作ツール販売をやっている。
書いているもの:
- ALTER - 副業の commit とダイエットの歩数を1つのキャラで育てる、焦らせない人生 OS(SaaS)
(その他:(なし))