【個人開発】万年下痢症の男が胃腸の弱い人のための健康管理アプリ「Puri.log」を作りました

# はじめに
こんにちは、nomotoと申します。
プログラミングスクールRUNTEQ53期生です。
Puri.logというwebアプリをリリースしました。

この記事では、アプリの概要と開発背景に軽く触れた後、機能の実装において、工夫した部分の手法と思考について詳しく書いていきます。

https://www.puri-log.com/

https://github.com/tkhero555/Puri.log


# アプリ概要
胃腸の弱い人のための食事と排便の記録アプリです。
あなたの胃腸と食事の相性を、記録を元にアプリが判定します。
LINEBOTを活用し、手軽に記録ができるようになっています。


# このアイデアに決めた理由
私は生まれつき胃腸が弱く下痢症で、中学時代には毎朝腹痛で通学電車を途中下車してトイレに行く人生でした。
社会人になってから自身の腹痛の要因が食事にあるのでは？と考え、色々な食事を試しては胃腸の反応を観察することを始めました。
何を食べると調子を崩すのかを少しずつ把握していった私は、相性の悪い食事を極力避けて生活して症状を改善することができました。
28歳となった今でも、自分の胃腸と食事の相性を意識して食べるものを決めているのですが、これを記録するだけでサポートしてくれるアプリが欲しいと考えました。
しかもアプリにすることで、同じような悩みを持った人の助けにもなるのではと思い、このアプリを作ることを決めました。


# 開発過程の記録
開発中の思考、検討、作業の内容をnotionに常時記録しながら進めていました。
記事というほどのものでは無いですが、バグ修正などの際に、実装した時の思考や手法を確認できるドキュメントとして活用できています。

https://www.notion.so/7f065b4344034b778e88d8c8a4b7a6ce


# 工夫した機能の手法と思考
自分なりに考え、工夫して実装した機能について、その手法や思考を語ります。

### テーブル構成と担わせている役割
- usersテーブル：ユーザーの情報を保存
- stoolsテーブル:排便の状態と日時、紐づくユーザーを保存
- mealsテーブル:食事名と紐づくユーザーを保存
- eatingsテーブル:誰が何をいつ食べたかを保存
![スクリーンショット 2024-06-01 15.58.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3768250/1fff3fcb-815f-53db-2ad6-d087171cc01e.png)


### 相性判定の仕組み
排便の状態を記録した際に、登録済みの食事のscoreカラムの値を増減させることで、ユーザーと食事の相性を判定する機能があります。
食べたものが消化される時間には個人差があるものの、24時間〜48時間であると言われています。
この考え方をコード上で表現したいと考えた結果、
下記のようにcreateアクションに相性判定機能のロジックを組んでいます。
```ruby
def create
  if params[:condition] == ""
    condition = Stool::STOOL_LOG_CONDITION_NORMAL
  else
    condition = params[:condition].to_i
  end
  stool = Stool.new(condition: condition, user_id: current_user.id, created_at:params[:created_at])
  unless stool.save
    flash.now[:danger] = '排便の記録に失敗しました'
    render("user/show")
    return
  end
```
排便記録フォームが送信されると、createアクションがparams[:condition]とparams[:created_at]を受け取リます。
conditionカラムはenumになっています。 { good: 0, normal: 1, bad: 2 }
その値を使用して、まずはstoolsテーブルに新規データを登録します。
保存に失敗した場合は、その時点で処理を中断してエラーメッセージを表示させます。
```ruby
if condition == Stool::STOOL_LOG_CONDITION_GOOD
  score_change = Meal::MEAL_SCORE_CHANGE_PLUS
elsif condition == Stool::STOOL_LOG_CONDITION_BAD
  score_change = Meal::MEAL_SCORE_CHANGE_MINUS
else
  flash[:notice] = '排便を記録しました'
  redirect_to user_path(current_user)
  return
end
start_time = stool.created_at - Eating::DETERMINING_COMPATIBILITY_START_TIME.hours
end_time = stool.created_at - Eating::DETERMINING_COMPATIBILITY_END_TIME.hours
eatings = Eating.where(created_at: start_time..end_time).where(user_id: current_user.id)
eatings.each do |eating|
  meal = Meal.find(eating.meal_id)
  unless meal.update(score: meal.score + score_change)
    flash.now[:alert] = '排便の記録に失敗しました'
    redirect_to user_path(current_user) and return
  end
end
flash[:notice] = '排便を記録しました'
redirect_to user_path
```
排便の状態を格納している変数conditionの値に応じて、食事データのscoreカラムの増減値を予め作成します。
ここでは、各モデルで設定している定数を使用しています。

排便の状態がnormal: 1で記録された場合は、食事scoreの変動は行わないため、その場で動作を完了させます。
排便を記録した時間から50時間前をstart_time、排便を記録した時間から20時間前をend_timeとして設定し、その範囲内に記録された食事記録を変数eatingsに格納します。
繰り返し処理で順番にデータを取り出し、対応する食事名をmealsテーブルから特定し、そのscoreカラムを増減させます。
これにより、排便を記録した際に、その原因となった可能性のある食事名の評価scoreを増減させる仕組みを実現しています。


### マイページの表示コンテンツ
マイページで日々の記録を視覚的に楽しめるようにすることで、面倒な記録作業に対する報酬を用意し、記録作業のモチベーションをサポートしたい狙いがあります。
4つのテーブルのデータを組み合わせて、加工して、表示コンテンツを作っています。
controllerでデータをインスタンス変数に格納して、viewファイルで使用しています。

- 登録済みの食事名一覧
![スクリーンショット 2024-06-12 14.04.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3768250/ddc3cff8-0bfe-5213-fec7-ed63207dc89c.png)

```erb
<% @meals.each do |meal| %>
  <% meal_id = meal.id %>
  <tbody>
    <tr>
      <th></th>
      <td><%= meal.meal_name %></td>
      <td><%= @my_meal_count[meal_id] %></td>
      <th></th>
    </tr>
  </tbody>
<% end %>
```
表示部では@mealsと@my_meal_countの2つのインスタンス変数を組み合わせて、一覧表示を作っています。

使用するインスタンス変数の作成
```ruby
@my_meal_count = current_user.eatings.group(:meal_id).count
@meals = current_user.meals
```
@my_meals_countはユーザーに紐づくeatingsテーブルのデータを、meal_idを基準にグループ化し、グループごとのデータ数をカウントしています。
@mealsにはユーザーに紐づく食事名のデータを格納しています。
@mealsから繰り返し処理でデータを取り出し、そのデータからmeal_nameを表示、meal.idを使って@my_meal_countから対応するデータを取り出して、記録回数の表示に使っています。

- 連続記録日数
![スクリーンショット 2024-06-12 14.46.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3768250/048f6ec1-f387-b002-91d9-6b73759ee3dc.png)

```erb
<h3><strong>食事の連続記録日数</strong></h3>
<div class="flex items-center justify-center text-7xl meal-log-days-record">
  <%= @meal_log_days_record %>
</div>
<div class="text-right text-3xl">日</div>
<h3><strong>排便の連続記録日数</strong></h3>
<div class="flex items-center justify-center text-7xl stool-log-days-record">
  <%= @stool_log_days_record %>
</div>
```
今日の時点、何日間連続で記録を続けているかを、食事と排便それぞれで表示しています。
@meal_log_days_recordと@stool_log_days_recordの2つのインスタンス変数を使用しています。

使用するインスタンス変数の作成
```ruby
# app/controllers/users_controller.rb
    
@meal_log_days_record = current_user.set_instance_meal_log_days
@stool_log_days_record = current_user.set_instance_stool_log_days
    
    
# app/models/user.rb
    
def set_instance_meal_log_days
  meal_log_days_record = 0
  meal_start_day = Time.zone.now.beginning_of_day
  meal_end_day = Time.zone.now.end_of_day

  while eatings.where(created_at: meal_start_day..meal_end_day).exists?
    meal_log_days_record += 1
    meal_start_day -= 1.day
    meal_end_day -= 1.day 
  end

  meal_log_days_record
end

def set_instance_stool_log_days
  stool_log_days_record = 0
  stool_start_day = Time.zone.now.beginning_of_day
  stool_end_day = Time.zone.now.end_of_day

  while stools.where(created_at: stool_start_day..stool_end_day).exists?
    stool_log_days_record += 1
    stool_start_day -= 1.day
    stool_end_day -= 1.day
  end

  stool_log_days_record
end
```
1日の範囲をmeal_start_dayとmeal_end_dayで指定します。
ユーザーに紐づくeatingsのデータに、その範囲の記録があるかを条件式としてwhileで繰り返し処理を行います。
処理の中で、日数のカウントを1増やし、範囲指定の変数を1日前に設定します。
こうすることで指定範囲の記録が無くなるまで処理が繰り返され、何日連続で記録が行われているかが算出されます。

- 記録履歴一覧
![スクリーンショット 2024-06-12 14.46.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3768250/ee0c6b6b-57c7-4ab5-884a-0d0507c5389b.png)

```erb
<% @log_index.each do |log| %>
  <% if log.is_a?(Eating) %>
    <tbody>
      <tr>
        <th></th>
        <td><%= log.meal.meal_name %></td>
        <td>-</td> 
        <td><%= log.created_at.strftime('%Y-%m-%d %H:%M') %></td>
        <% unless current_user.id == @recorded_test_user_id %>
          <td><%= link_to '削除', eating_path(log.id), data: { turbo_method: :delete, turbo_confirm: '削除しますか？' } %></td>
        <% end %>
        <th></th>
      </tr>
    </tbody> 
  <% elsif log.is_a?(Stool) %>
    <tbody>
      <tr>
        <th></th>
        <td>-</td> 
        <% if log.condition == "good" %>
          <td>良い</td>
        <% elsif log.condition == "normal" %>
          <td>普通</td>
        <% elsif log.condition == "bad" %>
          <td>悪い</td>
        <% else %>
          <td><%= log.condition %></td>
        <% end %>
        <td><%= log.created_at.strftime('%Y-%m-%d %H:%M') %></td>
        <% unless current_user.id == @recorded_test_user_id %>
          <td><%= link_to '削除', stool_path(log.id), data: { turbo_method: :delete, turbo_confirm: '削除しますか？' } %></td>
        <% end %>
        <th></th>
      </tr>
    </tbody> 
  <% end %>
<% end %>
```
@log_indexから取り出したオブジェクトのクラスをis_a?メソッドで判別し、表示を分岐しています。
これはユーザーの記録に食事と排便の2種類が存在し、データの種別によって表示される内容も違うためです。

使用するインスタンス変数の作成
```ruby
def set_log_index
  meal_log_index = current_user.eatings.includes(:meal).to_a
  stool_log_index = current_user.stools.to_a
  @log_index = meal_log_index.concat(stool_log_index)
  if params[:desc]
    @log_index = @log_index.sort_by { |log| log.created_at }.reverse
    session[:sort] = "desc"
  elsif params[:asc]
    @log_index = @log_index.sort_by { |log| log.created_at }
    session[:sort] = "asc"
  else
    if session[:sort] == "desc"
      @log_index = @log_index.sort_by { |log| log.created_at }.reverse
      session[:sort] = "desc"
    elsif session[:sort] == "asc"
      @log_index = @log_index.sort_by { |log| log.created_at }
      session[:sort] = "asc"
    end
  end
end
```
アソシエーションを活用して食事と排便の記録データを取り出して配列化、conatメソッドで合体させています。

この表示には時系列順の並び替え機能があり、並び替えボタンからのparamsとsessionの値を確認して、昇順と降順で並び替えを行います。


### マイページのグラフ
さらに視覚的に楽しくデータを確認できるように、グラフを採用しています。
グラフの生成にはchartkick gemを採用しており、controllerでデータをグラフ用の配列に整形して、chartkickに引き渡しています。

https://chartkick.com/

- 一例として排便の状態グラフ
![スクリーンショット 2024-06-12 14.49.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3768250/b139db17-1854-cf2f-9a30-833fa588becb.png)

このグラフの表示部はviewファイルでは下記のようになっています。
```erb
<%= pie_chart @stool_condition_count, colors: ["#008000", "#ffa500", "#ff0000"] %>
```

引き渡しているインスタンス変数の作成
```ruby
# app/controllers/users_controller.rb

@stool_condition_count = current_user.set_instance_stool_condition_count_graph

# app/models/user.rb

def set_instance_stool_condition_count_graph
  data = stools.group(:condition).count.to_a
  data.map! do |key, value|
    if key == "good"
      ["良い", value]
    elsif key == "normal"
      ["普通", value]
    elsif key == "bad"
      ["悪い", value]
    end
  end
end
```
モデルでロジック部分をメソッドとして定義しています。
current_userに紐づく排便記録をconditionカラムでグループ化し、そのデータ数をカウント
そのままグラフに引き渡すと英語表記になるため、強引に日本語に変換して再度配列化しています。

同様の手法で、月毎の記録回数、胃腸の調子の推移といったグラフも実装しています。


### 非同期処理をturboで実装
ユーザーの操作を快適にすることを目的に、アプリ内の一部に非同期処理を実装しています。
実装にはturboを使用しています。

- 記録履歴一覧の並び替えボタン
マイページのビューで記録履歴一覧のパーシャルをturbo_frameの対象に設定しています。
```erb
<%= turbo_frame_tag "log_index_frame" do %> 
  <%= render 'shared/log_index' %>
<% end %>
```
呼び出しているパーシャルの中に並び替えボタンが設置してあります。
ボタンを押すと、users_controllerのsortアクションがturbo_streamで起動し、自動的に対応するusers/sort.turbo_stream.erbが呼び出されます。
```erb
<%= link_to '古い順', sort_log_users_path(asc: "true"), data: { turbo_method: :post, turbo_frame: "log_index_frame" }, class: "bg-base-300 hover:bg-base-200 font-bold py-2 px-4 rounded-full w-20 asc-button" %>
<%= link_to '新しい順', sort_log_users_path(desc: "true"), data: { turbo_method: :post, turbo_frame: "log_index_frame" }, class: "bg-base-300 hover:bg-base-200 font-bold py-2 px-4 rounded-full w-24 desc-button" %>
```

turbo_streamのupdateアクションでturbo_frameの中身を更新しています。
```erb
<!-- app/views/users/sort.turbo_stream.erb -->

<%= turbo_stream.update "log_index_frame" do %>
  <%= render 'shared/log_index' %>
<% end %>
```
このままパーシャルを更新するだけだと、何も変わりませんが、sortアクションで表示部に引き渡すインスタンス変数の中身を並び替えています。
```ruby
def sort
  set_log_index
end

private

# 記録履歴一覧用のインスタンス変数を作成、並び替え
def set_log_index
  meal_log_index = current_user.eatings.includes(:meal).to_a
  stool_log_index = current_user.stools.to_a
  @log_index = meal_log_index.concat(stool_log_index)
  if params[:desc]
    @log_index = @log_index.sort_by { |log| log.created_at }.reverse
    session[:sort] = "desc"
  elsif params[:asc]
    @log_index = @log_index.sort_by { |log| log.created_at }
    session[:sort] = "asc"
  else
    if session[:sort] == "desc"
      @log_index = @log_index.sort_by { |log| log.created_at }.reverse
      session[:sort] = "desc"
    elsif session[:sort] == "asc"
      @log_index = @log_index.sort_by { |log| log.created_at }
      session[:sort] = "asc"
    end
  end
end
```
このset_log_indexメソッドではまず、ユーザーに紐づいた食事記録と排便記録をconcatメソッドで合体させた配列を作成しています。
並び替えボタンからは:descか:ascのどちらかのパラメータが送られてくるので、そのどちらがtrueかを見て押されたボタンを判断し、配列を並び替えます。
その時、session[:sort]に対応する文字列を格納しておきます。
並び替えボタン以外のルートからこのset_log_indexが実行され、params[:desc]も、params[:asc]も存在しない場合は、session[:sort]の値をチェックして並び替えを行います。
これにより、セッションが破棄されていなければ、以前の並び替え状態を引き継ぐことができます。


### line_bot_controller
Puri.logではLINE BOTから操作を行うことができます。
食事の度、トイレの度に記録作業をするこのアプリでは、スマホからの使用を想定しています。
最も記録作業の手順を簡略化できる手段として、LINE BOTを採用しました。

LINE BOTの動作はline_bot_controllerによって作られています。
ユーザーから何かが送られてきた際に起動し、受信したメッセージ内容を取り出し、その中身に応じた動作を実行します。
下記が、中身をチェックして条件分岐を行うまでのコードです。
```ruby
  def callback
    events.each do |event|
      client.reply_message(event['replyToken'], message(event))
    end
  end

  private

  def client
    @client ||= Line::Bot::Client.new { |config|
      config.channel_secret = ENV["LINE_CHANNEL_SECRET"]
      config.channel_token = ENV["LINE_CHANNEL_TOKEN"]
    }
  end

  def request_body
    request_body = request.body.read
  end

  def events
    # gemのメソッドであるparse_events_from(body)でevents配列を取得する。
    events = client.parse_events_from(request_body)
  end

  def message(event)
    # caseを使用して、eventの種類の条件をwhenで指定して、種類ごとに処理を行う。
    case event
    # eventがMessageかどうかをチェック
    when Line::Bot::Event::Message
    # ネストしてcaseを使用、eventのさらにタイプで分類
      case event.type
      # MessageType::Textかどうかをチェック
      when Line::Bot::Event::MessageType::Text
        case event.message["text"]
        when "登録済の食事"
          make_logged_meals(event)

        when "排便の記録"
          make_stool_button_message
          
        when "0", "1", "2"
          make_stool_log(event)

        when "おすすめの食事"
          make_recommend_meals(event)

        when "避けるべき食事"
          make_avert_meals(event)

        when "使用説明"
          make_explain

        # 食事メニューの記録を行う
        else
          make_meal_log(event)
        end
      end
    end
  end
```
こまでが、LINEのmessagingAPIからアクションが起動され、送られたメッセージの中身を条件分岐として設定している部分です。
分岐ごとにデータベースを操作したりしつつ、返信内容を作成するprivateメソッドを定義しています。


- when “登録済の食事”に紐づくmake_logged_meals(event)メソッドは、ユーザーが登録している食事名とその記録回数の一覧をメッセージにして返信する機能です。
```ruby
  def make_logged_meals(event)
    set_user(event)
    # ユーザーに紐づく食事記録を食事名ごとにグループ化してカウント
    meal_log_count = Eating.where(user_id: @user_id).group(:meal_id).count
    my_meals = Meal.where(user_id: @user_id)
    logged_meals = ""
    if my_meals.empty?
      logged_meals = "まだ食事が登録されていません"
    else
      my_meals.each do |my_meal|
        my_meal_id = my_meal.id
        # 食事名：記録回数という形式の文字列を作成して変数logged_mealsに追加
        logged_meals << "#{my_meal.meal_name} : #{meal_log_count[my_meal_id]}回\n"
      end
    end
    message = {
                type: "text",
                text: "食事名 : 記録回数\n#{logged_meals}"
              }
    message = achievement(@user, message)
  end
```
my_meals_countはユーザーに紐づくeatingsテーブルのデータを、meal_idを基準にグループ化し、グループごとのデータ数をカウントしています。
my_mealsにはユーザーに紐づく食事名のデータを格納しています。
my_mealsから繰り返し処理でデータを取り出し、そのデータからmeal_nameを、meal.idを使ってmy_meal_countから対応する記録回数を取り出して、返信用の変数に追加しています。
（最後に実行しているachievementメソッドは後述）

- when "排便の記録"に紐づくmake_stool_button_messageメソッドは、3つの選択肢付きのメッセージを作成します。
選択肢付きのメッセージを作成するコードはserviceファイルになっており、そのserviceファイルをprivateメソッド内で呼び出しています。
```ruby
def make_stool_button_message
    message = LineBot::Messages::UnkoMessage.new.button_message
end
```
呼び出しているserviceファイルは下記で、LINE BOTに引き渡すためのjson形式のデータを作っています。
```ruby
module LineBot
  module Messages
    class UnkoMessage
      def button_message
        {
          "type": "template",
          "altText": "This is a buttons select stools condition",
          "template": {
            "type": "buttons",
            "title": "排便の記録",
            "text": "選択肢から便の状態を選択してタップしてください。",
            "actions": [
              {
                "type": "message",
                "label": "0:良い",
                "text": "0"
              },
              {
                "type": "message",
                "label": "1:普通",
                "text": "1"
              },
              {
                "type": "message",
                "label": "2:悪い",
                "text": "2"
              }
            ]
          }
        }
      end
    end
  end
end
```

- when "0", "1", "2"に紐づくmake_stool_log(event)メソッドは、送られた数字に対応する排便の状態をデータベースに記録します。
```ruby
  def make_stool_log(event)
    set_user(event)
    stool_log = event.message["text"].to_i
    stool = Stool.new(condition: stool_log, user_id: @user_id)
    unless stool.save
      message = {
        type: "text",
        text: "排便の記録に失敗しました。やり直してください。"
      }
      return
    end
    if stool_log == Stool::STOOL_LOG_CONDITION_GOOD
      score_change = Meal::MEAL_SCORE_CHANGE_PLUS
    elsif stool_log == Stool::STOOL_LOG_CONDITION_BAD
      score_change = Meal::MEAL_SCORE_CHANGE_MINUS
    else
      score_change = Meal::MEAL_SCORE_CHANGE_ZERO
    end
    # ユーザーに紐づく食事記録から、特定の時間範囲の記録を取り出す。時間範囲は定数で始点と終点が設定されている。
    eatings = Eating.where(created_at: Eating::DETERMINING_COMPATIBILITY_START_TIME.hours.ago..Eating::DETERMINING_COMPATIBILITY_END_TIME.hours.ago).where(user_id: @user_id)
    stool_log_reply_message = "排便の記録が完了しました。\n\n\n【今記録した便の元となっている可能性がある食事一覧】\n\n食事名：日時\n"
    eatings.each do |eating|
      meal = Meal.find(eating.meal_id)
      unless meal.update(score: meal.score + score_change)
        stool_log_reply_message = "排便の記録に失敗しました。やり直してください。"
        break
      end
      # 食事名：記録日時という形式の文字列を作成して、変数に追加
      stool_log_reply_message << "#{meal.meal_name}：#{meal.created_at.strftime("%-m-%d %H:%M")}\n"
    end
    message = {
                type: "text",
                text: stool_log_reply_message
              }
    message = achievement(@user, message)
  end
```
メソッド内ではまず、排便記録をデータベースに保存します。
メッセージの内容をevent.message["text"]で取得し、その値をcoditionカラムへ、メッセージを送信したユーザーのidをuser_idに指定してstoolsテーブルのデータを作成します。

その後に食事の評価値増減の操作を実行します。
```ruby
  if stool_log == Stool::STOOL_LOG_CONDITION_GOOD
    score_change = Meal::MEAL_SCORE_CHANGE_PLUS
  elsif stool_log == Stool::STOOL_LOG_CONDITION_BAD
    score_change = Meal::MEAL_SCORE_CHANGE_MINUS
  else
    score_change = Meal::MEAL_SCORE_CHANGE_ZERO
  end
```
この部分では各モデルで定義してある定数を使用して、排便の状態に応じて食事scoreの変動値を決定し、score_change変数に格納しています。
記録した排便の原因かもしれないeatingsテーブルのデータをcreated_atカラムの範囲指定で抽出し、それと紐づくmealsテーブルのデータを特定し、そのscoreカラムをscore_change変数の値で変動させます。
そして、scoreカラムを変動させた食事名をユーザーに通知するために、返信メッセージを格納する変数stool_log_reply_messageに追加しています。

- when "おすすめの食事"に紐づくmake_recommend_meals(event)メソッドは、ユーザーの記録をもとに相性scoreの高い食事名をデータベースから取り出し、メッセージで通知します。
("避けるべき食事"も同様のロジック)
```ruby
  def make_recommend_meals(event)
    set_user(event)
    # ユーザーに紐づく食事記録から、scoreカラムが第一基準値以上のものを取り出す。基準値は定数で設定されている。
    recommend_meals = Meal.where('score >= ?', Meal::RECOMMEND_MEAL_JUDGE_FIRST_POINT).where(user_id: @user_id)
    if recommend_meals.empty?
      # ユーザーに紐づく食事記録から、scoreカラムが第二基準値以上のものを取り出す。基準値は定数で設定されている。
      recommend_meals = Meal.where('score >= ?', Meal::RECOMMEND_MEAL_JUDGE_SECOND_POINT).where(user_id: @user_id)
      if recommend_meals.empty?
        recommend_meal = "おすすめの食事はありません"
      else
        recommend_meal = recommend_meals.map { |meal| meal.meal_name }.join("\n")
      end
    else
      recommend_meal = recommend_meals.map { |meal| meal.meal_name }.join("\n")
    end
    message = {
                type: "text",
                text: recommend_meal
              }
    message = achievement(@user, message)
  end
```
ユーザーに紐づくmealsテーブルのデータから、scoreカラムの値が基準値を超えるものを取り出し、返信メッセージ用変数recommend_mealに格納しています。
基準値はMealモデルで定数が設定されています。

- when "使用説明"に紐づくmake_explainメソッドは、LINE BOTの使用説明テキストをユーザーに送信します。
```ruby
def make_explain
  message = {
             type: "text",
             text: "① **LINE連携ログインの確認**

- このLINEBOTは、サイトでのLINE連携ログインが完了していることが前提条件です。
- まだの方は、サイトにアクセスしてログインを実施してください。

② **登録済の食事**

- 今までに記録したことのある、食事名の一覧が確認できます。

③ **食事の記録**

- 食事名をメッセージで送信すると、その時刻に食べたものとして記録されます。

④ **排便の記録**

- 排便の記録ボタンを押すと、3択の選択肢ボタンが送られてきます（[0: 良い, 1: 普通, 2: 悪い]）。
- 自分の便の状態を3段階で判断して、該当するボタンをクリックしてください。クリックした状態と時刻で記録されます。

⑤ **おすすめの食事・避けるべき食事**

- それぞれのボタンをクリックすると、あなたと相性の良い食事、悪い食事が送られてきます。
- まだ判定ができていない場合は、データが足りていないので、記録を継続してから再度試してください。"
    }
end
```
privateメソッド内で、メッセージ返信用の変数に直接、説明文のテキストを格納しています。

- 条件指定以外のメッセージは食事記録として扱う
設定されている条件の文字列は全て、LINEのリッチメニューボタンを操作して送られる想定です。
そのため、ユーザーが直接文字を入力して送信するメッセージは、全て食事名の記録として扱う設定にしています。
make_meal_log(event)メソッドは、受け取ったメッセージ内容を食事名として記録をデータベースに保存します。
```ruby
  def make_meal_log(event)
    set_user(event)
    # 入力されたテキストを受け取り、mealsテーブルにそれが無ければ新規に登録する
    meal_log = event.message["text"]
    meal = Meal.find_by(meal_name: meal_log, user_id: @user_id)
    if meal.present?
      meal_id = meal.id
    else
      meal = Meal.new(meal_name: meal_log, user_id: @user_id)
      meal.save
      meal_id = meal.id
    end
    # evaluationテーブルにデータを保存する。評価値であるscoreのデフォルトは0
    # saveの成否に応じてユーザーへの返信内容を設定する
    eating = Eating.new(user_id: @user_id, meal_id: meal_id)
    if eating.save
      meal_log_reply_message = "食事の記録が完了しました。"
    else
      meal_log_reply_message = "食事の記録に失敗しました。やり直してください。"
    end
    message = {
                type: "text",
                text: meal_log_reply_message
              }
    message = achievement(@user, message)
  end
```
ユーザーの入力した食事名が初めてのものであれば、mealsテーブルとeatingsテーブルそれぞれのデータを作成します。
既に記録したことのある食事名の場合は、その食事名のmealsテーブルのデータのidを取得し、そのidを使用してeatingsテーブルのデータを作成します。

- 各メソッド内で使用されているachievement(user, message)メソッド
このメソッドは実績情報をユーザーに通知します。
特定のユーザーへのメッセージ送信に付随して、実績情報のメッセージを送るようになっています。
```ruby
  def achievement(user, message)
    user_id = user.id

    #my_scoreの判定
    eating_count = Eating.where(user_id: user_id).count
    stool_count = Stool.where(user_id: user_id).count
    my_score = eating_count + stool_count

    # meal_log_days_recordの判定
    meal_log_days_record = 0
    meal_date = Date.today
    # ユーザーに紐づく食事記録に、meal_dateで設定した日付のものがあるか判別
    while Eating.where(user_id: user_id).where("DATE(created_at) = ?", meal_date).exists?
      meal_log_days_record += 1
      meal_date = meal_date.prev_day
    end

    # continuation_daysの判定
    date_registered = user.created_at.to_date
    today = DateTime.now.to_date
    continuation_days = (today - date_registered).to_i

    # first_meal second_meal third_mealの判定
    # ユーザーに紐づく食事記録を、食事名ごとにグループ化しカウントした結果をvalueを基準に降順で並び替え
    my_meal_counts = Eating.where(user_id: user_id).group(:meal_id).count.sort_by{|key, val| -val}
    first_meal = Meal.find(my_meal_counts[0][0]).meal_name
    first_meal_count = my_meal_counts[0][1]
    second_meal = Meal.find(my_meal_counts[1][0]).meal_name
    second_meal_count = my_meal_counts[1][1]
    third_meal = Meal.find(my_meal_counts[2][0]) .meal_name
    third_meal_count = my_meal_counts[2][1]
    # 実績の返信内容を作成する
    achievement_message = "【現在の実績】\n・スコア：#{my_score}ポイント\n・#{meal_log_days_record}日連続記録達成中！(食事)\n・アプリを始めてから#{continuation_days}日経過\n\n【記録回数Top3の食事名】\n1.#{first_meal}：#{first_meal_count}回\n2.#{second_meal}：#{second_meal_count}回\n3.#{third_meal}：#{third_meal_count}回"
    achievement = [
                message,
                {
                  type: "text",
                  text: achievement_message  # ここに追加したいメッセージの内容を書く
                }
              ]
  end
```
メソッド内では4つの実績情報を作成しています。
1.ユーザーのスコア
2.本日時点の連続記録日数
3.アプリに登録してからの経過日数
4.記録回数の多い食事名TOP3

1.ユーザーのスコア
ユーザーに紐づくeatingsテーブルとstoolsテーブルのデータ数をカウントし、合計した数字をスコアとして表示しています。

2.本日時点の連続記録日数
meal_dateに本日の日付を格納し、それを基準にeatingsテーブルのデータを検索します。
検索がヒットする場合は、記録日数のカウントを1増やし、meal_dateを1日前に変更し、再度検索を繰り返します。
whileを使って、検索がヒットする限り繰り返すことで、本日時点で何日連続で記録をしているのかを算出しています。

3.アプリに登録してからの経過日数
userデータのcreated_atから本日の日付までの日数をカウントしています。

4.記録回数の多い食事名TOP3
ユーザーに紐づくeatingsテーブルのデータを、meal_idカラムでグループ化し、countしてsort_byして並び替えています。
結果としてできた配列から、記録回数が多い順に3つのデータを取り出し、meal_idを利用して、mealsテーブルから食事名を取得して変数に格納しています。


作成した4つの項目を組み合わせて文字列にして、メッセージ返信用の変数に格納することで、実績通知のメッセージをユーザーへ送信しています。

### 定期実行rakeタスクで通知機能
ユーザーが記録を忘れている場合に、それを知らせることで記録作業を促したいと考え、アプリ側から能動的に通知を送信する機能を作りました。
デプロイ先のherokuのアドオンで定期的にrakeタスクを起動しています。
rakeタスクのコードは下記です。
```ruby
namespace :log_notice do
  desc "24時間記録が無いユーザーにlineメッセージを送信するタスク"
  task not_log_user_notice: :environment do
    current_time = Time.now
    users = User.all
    users.each do |user|
      if user.notifications_enabled
        last_eating = user.eatings.order(created_at: :desc).limit(1).first
        last_stool = user.stools.order(created_at: :desc).limit(1).first
        if last_eating.nil? || last_stool.nil?
          break
        elsif current_time - last_eating.created_at >= 24 * 60 * 60 || current_time - last_stool.created_at >= 24 * 60 * 60
          message = {
              type: 'text',
              text: "前回の記録から24時間が経過しました。\n忘れてしまう前に食事と排便を記録しましょう。"
          }
          client = Line::Bot::Client.new { |config|
              config.channel_secret = ENV["LINE_CHANNEL_SECRET"]
              config.channel_token = ENV["LINE_CHANNEL_TOKEN"]
          }
          client.push_message(user.uid, message)
        end
      end
    end
  end
end
```
コード内では全ユーザーを一つずつ取り出し、まずそのユーザーのnotifications_enabledカラムがtrueであるかをチェックしています。
notifications_enabledカラムはboolean型のカラムで、デフォルトはfalseに設定しています。
このカラムでユーザーの通知機能のON OFFを管理しています。

ユーザーの通知機能設定がONである(カラムがtrueである)場合は、最後に記録したデータを取得します。
もし、そのユーザーがまだ記録したことの無いユーザーである場合は、処理を終了します。
ユーザーの最後の記録のcreated_atを確認し、現在時刻から24時間以上前である場合、通知用のメッセージを作成し、push_messageメソッドを使用して、LINE BOTからメッセージを送信します。

### ページ内タブメニュー
マイページには全部で19個の表示要素があります。
そのまま一覧表示してしまうと、ユーザーが要素の場所を覚えにくく、目的の要素を一々探すことになってしまいます。
より快適な閲覧のために、ページ内にタブメニューを実装しています。
[![Image from Gyazo](https://i.gyazo.com/2353bc0f50fecfebe7bf77cace83fafc.gif)](https://gyazo.com/2353bc0f50fecfebe7bf77cace83fafc)

タブメニューはlink_toとコンテンツごとにidが振られたdivタグで構成されており、stimulusで動作を制御しています。
stimulusのコードは下記です。
```javascript
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="tab"
export default class extends Controller {
  static targets = ["tab", "link"]

  connect() {
    this.tabTargets.forEach(tab => {
      if (tab.id !== "tab1") {
        tab.style.display = "none";
      }
    });
  }

  showTab(event) {
    event.preventDefault();
    const targetId = event.currentTarget.getAttribute("href").substring(1);

    // 一旦全てのタブの中身を非表示にする
    this.tabTargets.forEach(tab => {
      tab.style.display = "none";
    });

    // 一旦すべてのリンクからactiveクラスを削除する
    this.linkTargets.forEach(link => {
      link.classList.remove("active");
    });

    // クリックされたリンクにactiveクラスを追加し、対応するタブを表示する
    event.currentTarget.classList.add("active");
    document.getElementById(targetId).style.display = "block";

    // 一旦すべてのリンクにhover:textt-blue-300を追加する
    this.linkTargets.forEach(link => {
      link.classList.add("hover:text-blue-300");
    });

    // クリックされたリンクからhover:textt-blue-300を取り除く
    event.currentTarget.classList.remove("hover:text-blue-300");
  }
}
```
クリックされたタブメニューのボタンに対応するコンテンツを表示させたり、ボタンのclassを追加、削除することで、適用されるtailwindCSSを変更し、見た目を変化させています。

正直なところ、javasctiptの書き方はまだまだ分かっておらず、GPTに日本語で動作を入力してコード化させたものを修正しながら作成しています。
次に記載するモーダルを制御するstimulusも同様の手法でコードを作成しました。


### 利用規約に同意させるモーダル表示
アプリに設定してある利用規約をユーザーに確認してもらいたい場合、ユーザーの新規登録画面などに設置し、利用規約への同意チェックをした上で、新規登録ボタンを押してもらうような仕様が一般的かと思います。
Puri.logでは、ヘッダーのLINE連携ボタンから直接ログインする動線にしているため、同意アクションの良い設置場所がありませんでした。
そこで、マイページへログインした際にモーダルを強制表示させることにしました。
ユーザーが利用規約を確認して同意すると、acceptedカラムがtrueになり、モーダルは表示されなくなります。
[![Image from Gyazo](https://i.gyazo.com/13ef9e8463f3e97fde7868205cef6819.gif)](https://gyazo.com/13ef9e8463f3e97fde7868205cef6819)

モーダルはdialog要素を使用して、マイページのviewファイルに設置しています。
dialog要素を使用して作ることで、デフォルトでは非表示の状態となり、それをstimulusで条件に応じて表示させています。
stimulusのコード
```javascript
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="modal"
export default class extends Controller {
  static targets = ["dialog"]

  connect() {
    const value = this.data.get("value");
    if (value == "true") {
      this.dialogTarget.showModal();
    }
  }

  close() {
    this.dialogTarget.close();
  }
}
```
connect()にモーダルの表示メソッドを設定することで、マイページに遷移して、このcontrollerが起動した時点で実行されるようになっています。
メソッドの中で、viewからvalueを取得して、その中身が"true"であるかをチェックしています。
下記のコードでインスタンス変数をvalueに引き渡しており、この中身はユーザーのacceptedカラムの値を反転したものが格納されています。
```erb
<div data-controller="modal" data-modal-value="<%= @user_accepted %>">
```
このチェック項目を通る　== そのユーザーはまだ利用規約に同意していない
という仕組みになっているので、モーダルを表示します。

close()は、利用規約に同意しないボタンと紐づいています。
どうしても一時的に無視してマイページの中身を確認したい場合に利用することができますが、改めてマイページに遷移した際には、またモーダルが表示されます。

こうして表示された利用規約を確認し、同意するボタンを押すと、
```erb
<%= link_to '同意する',  accept_terms_user_path, class: "py-4 px-4 md:px-6 mr-8 bg-primary-content hover:bg-base-200 font-bold p-2 rounded-full inline-block", data: { turbo_method: :post } %>
```
accept_terms_user_pathに設定されたaccept_termsアクションに飛びます。
```ruby
def accept_terms
  current_user.update(accepted: true)
  redirect_to user_path
end
```
アクション内では、ユーザーのacceptedカラムをtrueに更新し、マイページへリダイレクトしています。
リダイレクトした際には、すでに利用規約に同意済みのため、モーダルの表示は行われず、そのままアプリを利用することができるようになっています。


# 最後に
ご覧いただきありがとうございました。
アプリの開発をしてきた中で、何を考えて、どのように実装したのかを人に聞かせたい欲があり、このような記事内容にしてみました。
機能の実装内容が、誰かの役に立ったらいいなと思っています。

以上