習慣トラッカーの『ストリークが切れたら終わり』をDB設計で潰す
習慣化アプリを自作したことがあるC言語エンジニアなら分かると思うが、ストリーク(連続記録)はユーザーを一番やる気にさせる指標であると同時に、一番心をへし折る指標でもある。
自分は組み込み系の実務を23年やってきて、副業でダイエット記録と作業ログを兼ねた個人ツールをいくつか作ってきた。その過程で「ストリークが切れた瞬間にアプリを消される」現象を何度も見た。今回はそのDBスキーマと通知設計を、実際に手を動かして直した内容で書く。
対象は、習慣トラッカーや自己管理系のツールをC言語や組み込み向けのバックエンドで作ろうとしている人。あるいは既存のHabitica系サービスに挫折して、自分で作り直したい人向け。
結論を先に書く
ストリークが切れて心が折れる原因は、大体において「連続日数という一本の数値だけをDBに持っている」ことにある。
streak_count INTEGER の1カラムだけで管理していると、1日サボった瞬間に0にリセットされて、ユーザーが積み上げてきた実績が全部消えたように見える。
解決策は単純で、ストリークを「連続日数」ではなく「実施ログの集合」として持ち、表示側で複数の指標(連続日数、実施率、直近7日の達成数)を出し分けることだ。加えて通知は「サボった責め立て」ではなく「復帰しやすい導線」に設計し直す必要がある。
以下、実際にテーブル設計をどう変えたか、通知バッチをどう書き直したかを順番に説明する。
なぜストリークは切れると終わるのか
Habitica系のサービスを使っていた時期があるが、1日サボると連続日数が0に戻り、下手すると他ユーザーとのリーグで降格する仕組みまであった。心理的には「積み上げてきたものが一瞬で消える」という感覚に近い。
これはDB設計の問題でもある。多くの実装は users テーブルに current_streak という1カラムを持たせて、cronジョブで日次更新している。
// よくある実装(アンチパターン)
if (did_task_today) {
user->current_streak += 1;
} else {
user->current_streak = 0; // ここで積み上げが消える
}
この設計だと、DBの中には「今何連続か」という瞬間値しか残らない。過去90日で87日やったという事実は、どこにも記録として残らない。ユーザーから見ると、67日連続していたものが1日抜けただけで「67」という数字がどこにも表示されなくなる。
自分は最初に作ったツールでもこの設計をやってしまい、3週間分の運動記録が1日の風邪で消し飛んだように見えて、自分自身がアプリを開かなくなった経験がある。作った本人が使わなくなるのだから、設計として失敗していたと思う。
解決手順
ステップ1:ストリークをカラムではなくログテーブルにする
まず current_streak という数値カラムを廃止して、実施ログのテーブルだけを正とする。
CREATE TABLE habit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
habit_id INTEGER NOT NULL,
logged_date DATE NOT NULL,
value INTEGER DEFAULT 1,
UNIQUE(user_id, habit_id, logged_date)
);
連続日数は保存する値ではなく、このテーブルから毎回計算する導出値にする。SQLiteでもC側でも、SQLで直接出せる。
-- 直近から遡って連続している日数を計算する例
WITH RECURSIVE streak_days AS (
SELECT logged_date, 1 AS streak
FROM habit_logs
WHERE user_id = ? AND habit_id = ?
ORDER BY logged_date DESC
LIMIT 1
)
SELECT COUNT(*) FROM habit_logs
WHERE user_id = ? AND habit_id = ?
AND logged_date >= date('now', '-90 days');
厳密な再帰計算はC側のロジックに寄せた方が保守しやすい。自分は日付をepoch秒に変換して配列に詰め、隙間の有無を線形走査する方式に落ち着いた。SQLの再帰CTEだけで完結させようとして、可読性が落ちて後で自分が読めなくなったことがある。
ステップ2:表示する指標を1つに絞らない
DBを直しても、UIが「連続日数」しか出さなければ意味がない。自分は最終的に3つの指標を並べて出すようにした。
- 直近30日の実施率(例:30日中22日、73%)
- 最長連続記録(過去のベスト、消えない)
- 現在の連続日数(切れたら0からで良いが、他の指標と並べて出す)
これで1日抜けても「73%はキープしている」「ベスト記録は40日で今回は12日目」という情報が残る。連続日数だけがゼロになっても、他の指標がユーザーの積み上げを可視化し続ける。
typedef struct {
int current_streak;
int best_streak;
int last_30_days_rate; // パーセント、0-100
} habit_stats_t;
habit_stats_t calc_stats(sqlite3 *db, int user_id, int habit_id) {
habit_stats_t stats = {0};
// ログテーブルから3指標をまとめて計算する
// 個別のSQLクエリは省略(実装は日付配列を作ってC側でループが安定した)
return stats;
}
ステップ3:通知は「詰める」のではなく「戻す」設計にする
通知バッチも合わせて直した。多くの実装は「今日まだやってませんよ」という通知を毎日同じ文面で送る。これは3日目くらいから既読無視される。
自分が試して効果があったのは、抜けた日数によって文面を変える方式だ。
const char* build_notification(int days_since_last_log) {
if (days_since_last_log == 1) {
return "昨日は記録なし。今日1回やれば実施率はほぼ変わらない";
} else if (days_since_last_log <= 3) {
return "3日抜けても、過去のベスト記録はまだ残ってる";
} else {
return "久しぶり。ゼロから再開でも記録は全部見れる";
}
}
抜けた日数が増えるほど「責めない」方向に文面を弱めていく。逆説的だが、抜けた直後ほど強く行動を促し、長期間抜けた後は「戻ってきやすさ」を優先する設計にした方が、自分の場合は再開率が上がった。数字で言うと、文面固定だった時期の自己利用での再開率は体感5割程度だったが、抜け日数別に分岐させてからは体感7割前後には上がった実感がある(サンプルは自分含む数人分なので、厳密な統計ではない)。
落とし穴と対処
実装していて詰まったポイントを2つ書く。
1つ目は、タイムゾーンの扱い。logged_date をUTCで保存していたら、深夜0時〜朝方に記録したユーザーの「今日」がズレて、実施したのにストリークが切れて見える不具合が出た。ユーザーのローカルタイムゾーンをusersテーブルに持たせて、ログ保存時に変換する処理を後から追加する羽目になった。最初からタイムゾーンカラムを設計に入れておくべきだった。
2つ目は、UNIQUE制約の粒度。UNIQUE(user_id, habit_id, logged_date) にしたことで、1日に複数回記録したいケース(水分摂取など回数系の習慣)に対応できなくなった。回数系とON/OFF系で別テーブルに分けるか、value カラムで加算方式にするか、途中で設計を割ることになった。最初に「この習慣は0/1か、それとも回数か」を全習慣について洗い出しておけば手戻りは減らせたと思う。
この手のDB設計と通知設計、自分でツールを1本作るたびに毎回同じところで詰まっていた。最終的には副業のコミット状況とダイエットの記録を1つのDBで持って、ストリークが切れても他の指標で継続を可視化する仕組みを作り直した。それが今作っているALTERというツールになっている。
関連ツール
副業のコミットとダイエットの歩数を1つのキャラクターで育てる形にして、ストリークが切れても人生全体の積み上げが見える設計にしたのがALTERというサービス。今回書いたようなDB設計・通知設計の考え方を実装に落とし込んだものになる。
→ ALTER
まとめ
ストリークが切れて挫折する原因の多くは、連続日数を単一の数値として持ってしまう設計にある。ログテーブルを正として複数指標を導出する形にし、通知も抜けた日数に応じて文面を弱めていくことで、再開率は自分の体感でも上がった。
DB設計を1つ変えるだけで挫折率が下がるなら、コードを書き直すコストとしては割に合うと思う。
著者:ぽん(@pon_freelance)
C言語実務23年、組み込み/制御系。
副業で技術記事販売と自作ツール販売をやっている。
書いているもの:
- ALTER - 副業の commit とダイエットの歩数を1つのキャラで育てる、焦らせない人生 OS(SaaS)
(その他:(なし))