3
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?

【Rails】「心の火を絶やさない」習慣化ロジック。意外と難しい?連続記録(ストリーク)の実装

Posted at

はじめに

こんにちは!個人開発で、自己分析(他者評価も含め)を行い、自身の心の理解を深めるWebアプリ「Anta Santa」を開発している えりぃー と申します。

このアプリには感情の暖炉という機能があります。
自分の今の気持ち(感情)を薪としてくべることで暖炉の火が燃え続ける……という世界観で、ユーザーが毎日自分の心と向き合うことを習慣化するための機能です。

習慣化アプリにおいてモチベーションを支えるのが連続記録の表示です。
「おっ、30日続いているな」という達成感は大切ですよね。

しかし、この実装には「1日休んだらどうなる?」「昨日は書いたけど今日はまだ書いてない時は?」といった地味に複雑なロジックが潜んでいます。

さらに私は最初に大きな設計ミスをしていました。
それは、「1日記録を忘れた瞬間に、過去に獲得した『30日達成カード』まで没収される」という鬼仕様を作ってしまったことです……😱

今回は、私が実装した「感情ログの連続記録ロジック」と達成カード没収を防ぐ「永続化設計」、そしてそれらを保証する「RSpecでの全パターン検証」について皆様に共有したいと思います。


1. 悲劇:なぜ「達成カード没収」が起きたのか?

初期のダメな設計

最初はシンプルにこう考えていました。

  1. 毎回、ログの日付から「現在の連続日数」を計算する。
  2. その日数が「30」を超えていたら、画面に「30日達成カード」を表示する。

起きたこと

ユーザーが30日連続で記録し、達成カードをゲットして喜びます。
しかし、31日目にうっかり記録を忘れました。
すると翌日、「現在の連続日数」は「0」に戻ります。

プログラムはこう判断しました。
「現在の連続日数は0だな。30以上という条件を満たしていないから、カードは非表示にするね!

これが「実績の剥奪」です。ユーザーの努力を一瞬で無に帰す、あってはならない仕様でした。


2. 解決策:計算と実績を分ける

この問題を解決するためにロジックを2つに分けました。

  1. ストリーク計算: ログから「現在の連続日数」を計算する(これは毎日変動してOK)。
  2. 実績の永続化: 条件を達成した瞬間に、「カード獲得テーブル」に保存してしまう。

こうすれば、たとえ連続記録が途切れて「現在の数値」が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で「時間」を操りながら全パターンを検証することで、ユーザーに安心して使ってもらえる機能を実装できました。

感情と向き合うのは時としてしんどい作業ですが、こうして「連続記録」として可視化されることで、「自分はこれだけ自分の心と向き合ってきたんだ」という自信に繋がればいいなと思います。

これから習慣化機能を実装される方の参考になれば幸いです!

3
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
3
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?