はじめに
こんにちは!個人開発で、自己分析(他者評価も含め)を行い、自身の心の理解を深めるWebアプリ「Anta Santa」を開発している えりぃー と申します。
このアプリには感情の暖炉という機能があります。
自分の今の気持ち(感情)を薪としてくべることで暖炉の火が燃え続ける……という世界観で、ユーザーが毎日自分の心と向き合うことを習慣化するための機能です。
習慣化アプリにおいてモチベーションを支えるのが連続記録の表示です。
「おっ、30日続いているな」という達成感は大切ですよね。
しかし、この実装には「1日休んだらどうなる?」「昨日は書いたけど今日はまだ書いてない時は?」といった地味に複雑なロジックが潜んでいます。
さらに私は最初に大きな設計ミスをしていました。
それは、「1日記録を忘れた瞬間に、過去に獲得した『30日達成カード』まで没収される」という鬼仕様を作ってしまったことです……😱
今回は、私が実装した「感情ログの連続記録ロジック」と達成カード没収を防ぐ「永続化設計」、そしてそれらを保証する「RSpecでの全パターン検証」について皆様に共有したいと思います。
1. 悲劇:なぜ「達成カード没収」が起きたのか?
初期のダメな設計
最初はシンプルにこう考えていました。
- 毎回、ログの日付から「現在の連続日数」を計算する。
- その日数が「30」を超えていたら、画面に「30日達成カード」を表示する。
起きたこと
ユーザーが30日連続で記録し、達成カードをゲットして喜びます。
しかし、31日目にうっかり記録を忘れました。
すると翌日、「現在の連続日数」は「0」に戻ります。
プログラムはこう判断しました。
「現在の連続日数は0だな。30以上という条件を満たしていないから、カードは非表示にするね!」
これが「実績の剥奪」です。ユーザーの努力を一瞬で無に帰す、あってはならない仕様でした。
2. 解決策:計算と実績を分ける
この問題を解決するためにロジックを2つに分けました。
- ストリーク計算: ログから「現在の連続日数」を計算する(これは毎日変動してOK)。
- 実績の永続化: 条件を達成した瞬間に、「カード獲得テーブル」に保存してしまう。
こうすれば、たとえ連続記録が途切れて「現在の数値」が0になってもテーブルに保存された「過去の栄光(カード)」は消えません。
連続日数の計算ロジック (Userモデル)
DBに「現在の連続日数」という数字を持たせてインクリメントする方法もありますが、ズレが生じるリスクがあるため「ログの日付から毎回動的に計算する」アプローチを取りました。
# app/models/user.rb
def emotion_streak
# 1. ログの日付を取り出し、重複を排除して新しい順に並べる
log_dates = emotion_logs.order(created_at: :desc)
.pluck(:created_at)
.map { |time| time.in_time_zone.to_date }
.uniq
return 0 if log_dates.empty?
# 2. 最新の投稿が「今日」または「昨日」でなければ、すでに途切れている
latest_date = log_dates.first
return 0 if latest_date < Date.yesterday
# 3. 過去に遡って連続日数をカウント
streak = 0
check_date = latest_date
log_dates.each do |date|
if date == check_date
streak += 1
check_date -= 1.day # 1日ずつ遡ってチェック
else
break # 日付が飛んだらそこで終了
end
end
streak
end
3. RSpecで全パターンを徹底検証する
日付計算のロジックは、手動テストだと「明日また確認しなきゃ」となりがちで大変です。
そこでRailsの travel_to を使って、時間を自在に操りながらあらゆるパターンを検証しました。
テストの準備
# spec/models/user_spec.rb
describe '#emotion_streak' do
include ActiveSupport::Testing::TimeHelpers
let(:user) { create(:user) }
# ヘルパー: 指定した「何日前」の正午にログを作る
def create_log(days_ago, count: 1)
count.times do
create(:emotion_log, user: user, created_at: days_ago.days.ago.change(hour: 12))
end
end
# テスト実行中の「現在時刻」を固定する
around do |example|
travel_to(Time.zone.parse('2024-01-10 12:00:00')) do
example.run
end
end
# ...以下、各パターンの検証
パターンA:基本の継続判定
まずは正常系の確認です。
context '記録がある場合' do
# パターン1: 今日書いた
context '今日だけ記録している場合' do
before { create_log(0) } # 0日前 = 今日
it '継続日数: 1 を返すこと' do
expect(user.emotion_streak).to eq 1
end
end
# パターン2: 3日連続
context '3日連続で記録している場合' do
before do
create_log(2) # 一昨日
create_log(1) # 昨日
create_log(0) # 今日
end
it '継続日数: 3 を返すこと' do
expect(user.emotion_streak).to eq 3
end
end
end
パターンB:意外とハマる「1日複数回」問題
ユーザーが熱心に1日3回記録してくれました。これを「3日連続」と判定してはいけません。
# パターン3: 1日複数回
context '1日に複数回記録した場合' do
before do
create_log(1, count: 3) # 昨日3回
create_log(0, count: 2) # 今日2回
end
it '回数ではなく「ユニークな日付」でカウントし、継続日数: 2 を返すこと' do
# ロジック内で .uniq しているかが問われます
expect(user.emotion_streak).to eq 2
end
end
パターンC:セーフ判定(昨日は書いたけど今日はまだ)
ここがUXの肝です。「今日の分をまだ書いていない」=「途切れた」と判定すると、朝起きた瞬間に連続記録がリセットされてしまいます。「昨日の分があればセーフ」という猶予が必要です。
# パターン4: 今日はまだ書いてない(猶予期間)
context '昨日だけ記録している場合(今日はまだ)' do
before { create_log(1) } # 昨日
it '火はまだ消えていないとみなし、継続日数: 1 を返すこと' do
# latest_date < Date.yesterday の判定が効いているか
expect(user.emotion_streak).to eq 1
end
end
パターンD:途切れた時の判定
残酷ですが、サボった時は正しくリセットされなければなりません。
# パターン5: 1日サボった
context '記録が途切れている場合' do
before do
create_log(0) # 今日
create_log(1) # 昨日
# --- ここで1日空く(2日前はサボり) ---
create_log(3) # 3日前
create_log(4) # 4日前
end
it '過去の記録は無視し、直近の連続日数(2)のみを返すこと' do
expect(user.emotion_streak).to eq 2
end
end
# パターン6: 昨日も今日もサボった
context '一昨日まで連続記録し、昨日・今日サボった場合' do
before do
create_log(2) # 2日前
create_log(3) # 3日前
end
it '火が完全に消えていると判定し、0を返すこと' do
expect(user.emotion_streak).to eq 0
end
end
4. 最後に:「カードが消えないこと」の証明
最後に、冒頭の「鬼仕様」が解決されたことをテストで証明します。
Request Specで未来へタイムトラベルして検証します。
describe 'カードの永続化' do
it '連続記録が途切れても、一度獲得したカードは消えないこと' do
# 1. 3日連続投稿してカード獲得
travel_to 3.days.ago do; post_log; end
travel_to 2.days.ago do; post_log; end
travel_to 1.day.ago do; post_log; end
# ここでAPIを叩くと「3日連続カード」を持っているはず
get stats_api_path
expect(json['badges']['3_days']['earned']).to be true
# 2. ここで1週間サボる(連続記録が途切れる!)
travel_to 1.week.from_now do
# 久々の投稿
post_log
get stats_api_path
# ストリークは「1」に戻っているが...
expect(json['stats']['streak']).to eq 1
# ★カードは「獲得済み(true)」のままであること!
# DB保存方式にしたおかげで、これが通ります
expect(json['badges']['3_days']['earned']).to be true
end
end
end
まとめ
習慣化アプリにおいて、連続記録(ストリーク)はユーザーのモチベーションを支える大切な機能です。
- 1日複数回の投稿
- 「今日はまだ書いてない」の猶予判定
- 途切れた時のリセット処理
- 実績の永続化(没収防止)
これらを考慮したロジックを組み、RSpecで「時間」を操りながら全パターンを検証することで、ユーザーに安心して使ってもらえる機能を実装できました。
感情と向き合うのは時としてしんどい作業ですが、こうして「連続記録」として可視化されることで、「自分はこれだけ自分の心と向き合ってきたんだ」という自信に繋がればいいなと思います。
これから習慣化機能を実装される方の参考になれば幸いです!