0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

習慣トラッカーの『ストリークが切れたら終わり』をDB設計で潰す

0
Posted at

習慣トラッカーの『ストリークが切れたら終わり』を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)
    (その他:(なし))
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?