体調グラフ機能で複数カラムを動的に表示する方法を調べる

アプリ制作

実現したいこと(1)

複数カラムを選択して同時に描画する.

activity, appetite, moodの3カラムのボタンを設置する. 3つのボタンはON/OFFにすることができ、ONの状態にあるカラムをまとめて表示したい。

試したこと

Rails Chart.js Dynamically change columnで調べると、Ajaxを使った方法が多く見られた。

Ajaxを使用したことがないので、学習方法をメンターに質問。(進捗報告も兼ねて)

実現したいこと(2)

Diaryのカラムごとに色分けをしてグラフを表示する

どのように実装するか

Chartjs-ror gem を導入し, グラフのUIをRailsで設定できるようにする。

# example
data = {
  labels: ["January", "February", "March", "April", "May", "June", "July"],
  datasets: [
    {
        label: "My First dataset",
        backgroundColor: "rgba(220,220,220,0.2)",
        borderColor: "rgba(220,220,220,1)",
        data: [65, 59, 80, 81, 56, 55, 40]
    },
    {
        label: "My Second dataset",
        backgroundColor: "rgba(151,187,205,0.2)",
        borderColor: "rgba(151,187,205,1)",
        data: [28, 48, 40, 19, 86, 27, 90]
    }
  ]
}
options = { ... }

<%= line_chart data, options %>

コードを読んでみる。

  • dataハッシュ
    • labelsキー: X軸の値 (Array)
    • datasetsキー:単独または複数の datasetハッシュ (Array)
  • datasetハッシュ (ある期間における1カラムのグラフを表す)

    • labelキー : legend name (String)
    • backgroundcolorキー: グラフの背景色, rgba (String)
    • bordercolorキー: グラフの枠線の色, rgba(String)
    • dataキー: Y軸の値(Array)
  • Viewでline_chartメソッドを呼び出し, 引数にdata, optionを代入する

  • Viewでline_chartメソッドを呼び出し, 引数にdata, optionを代入する

使用したい色

#51a370 rgb(81,162,112)  Green  Mood
#ffeb8a rgb(255,235,138) YELLOW Appetite
#e86c4e rgb(232,108,77)  Orange Activity

その他の作業

  • 期間やカラムの移動に関するリンクをテンプレート化し、app/views/layoutsへ置いた

プロを目指す人のためのRuby入門

  • 10章 Procとラムダ(2時間)

Weeklyグラフで前後の期間へのリンク生成で発生するエラーをデバッグ


アプリ制作

実現したいこと1

リクエストが下の2つのパターン両方でグラフ描画を行えるようにする。前者はコントローラのreportbydateアクション, 後者はreportbyperiodアクションにルーティングされている。

statistics/activity/from/2019-10-07/to/2019-10-13, statistics/activity/period/prevWeek

課題1

statistics/activity/period/prevWeekで先週のグラフを表示する。これは2019-10-07/to/2019-10-13の期間のグラフが表示できている。

(1) 次に前週へのリンクを押し, 2019-09-30 ~ 2019-10-06の期間のグラフを表示する。しかし、この期間のレコードは存在するのにグラフには該当日付のY軸の値が表示されない。

(2) そのまたさらに前週へのリンクを押すと, 本来は2019-09-23 ~ 2019-09-29の期間がリクエストされるはずにもかかわらず, 2019-08-01 ~ 2019-09-29の期間がリクエストされる。

課題1がなぜ起こるのか?・修正すべき点と結果

binding.pryでコントローラの@reportが正しく生成されているか確認をした。

Image from Gyazo

2019-09-30 ~ 2019-10-06の期間, つまり1週間でリクエストをしているのに、MonthlyReportクラスが呼ばれていた。(期間が1ヶ月単位の時に呼び出すように設計したつもりだった)

リクエストされた期間に対して, コントローラ内でWeeklyReportかMonthlyReportかを条件分岐する部分が意図した通りに機能していない。その部分を修正する必要がある。

コントローラではis_date_weekly?メソッドでtrueならWeeklyReport, falseならMonthlyReportを生成するように分岐させていた。2019-09-30 ~ 2019-10-06のリクエストに対して, このメソッドでfalseを返してしまい, MonthlyReportが生成されてしまっていた。

def is_date_weekly?(date_begin, date_end)
  (date_begin..date_end).to_a.length <= 7 ? true : false
end

なぜそうなるかというと、メソッドの引数date_begin, date_endがStringクラスのまま代入されていた。Dateオブジェクトに変換するように修正すると, WeeklyReportを生成できた。

さらに発生した懸念点

MonthlyReportクラスではgroup_by_monthで月初の日付をキーにしてグループ化したハッシュを作成している。そのためdate_beginが月初の日付でないと, 該当期間のY軸の値をすべてnilにして配列で返してしまう.

(この,keyが見つからない場合にnilの配列を返す処理は, Diaryレコードがない期間のリクエストの際にエラー発生を防ぐ目的で実装していた。)

def data_generator(user, column, date_begin)
  records = user.diaries.group_by_month(&:diary_date)
  if records[date_begin].present?
    records[date_begin].map do |diary|
      diary.send(column)
    end
  elsif records[date_begin].nil?
    date_range.length.times.map { |n| nil }
  else
    raise "Error"
 end

グループしたHashのキーが存在しなかった場合, 該当期間のY軸がすべてnilになる可能性がある。group_byメソッドの処理を確認したり、Reportクラスの処理を修正する必要があるなと思った。

実現したいこと2

同じカラムで週間→月間, 月間→週間の遷移をする

どのように実装したか?

  1. 週間→月間
    • WeeklyReportクラスにto_monthlyメソッドを作成
    • date_beginのbeginning_of_month, end_of_monthを取得して, URI文字列を返す
  2. 月間→週間
    • MonthlyReportクラスにto_weeklyメソッドを作成
    • date_beginとdate_endの日数の差の2分の1の日付を基準日にし、基準日の週の月曜, 日曜をそれぞれdate_begin, date_endにセット
    • URI文字列を返す

Viewで@reportがWeekly, Monthlyどちらのクラスのインスタンかを確かめる

#app/views/statistics/report_by_date.html.erb
<% if @report.class.method_defined? :to_monthly %>
  <%= link_to "月間", "#{@report.to_monthly}" %>
<% end %>
<% if @report.class.method_defined? :to_weekly %>
  <%= link_to "週間", "#{@report.to_weekly}" %>
<% end %>

実装できた。

ここまでに実装した機能(体調グラフ)

  • URIの形式

    • 期間を表す単語で期間を指定する

      /statistics/:column/period/thisWeek

      • 単語は4種類 [thisWeek, thisMonth, prevWeek, prevMonth]

      • PeriodConstaraintクラスでリクエストのURIをこれら4種類のみに限定

    • 日付で期間を指定

      /statistics/:column/from/:date_begin/to/:date_end

      • DateConstraintクラスで日付を表す文字列かどうかを検証
  • 期間は週間、月間の2週類

  • それぞれの期間について、以下のことができる.

    • 前後の期間へのリンクを生成する機能(同カラム内)
    • 週間→月間、月間→週間へのリンクを生成する機能(同カラム内)
  • 同期間内で異なるカラムへのリンクを生成する機能

プロを目指す人のためのRuby入門

  • 第10章 Procとyieldを理解する(2時間)

キーワード引数にデフォルト値を設定し, 異なるパターンの引数を取れるようにする


アプリ制作

実現したいこと

リクエストが statistics/activity/from/2019-10-07/to/2019-10-13, statistics/activity/period/prevWeekの2つのパターン両方でグラフ描画を行えるようにする。前者はコントローラのreport_by_dateアクション, 後者はreport_by_periodアクションにルーティングされています。

実装方法

lib下のDateConstraintクラスでURIの/:datebegin, /:dateendを Date.valid_date?メソッドで検証する(year, month, dayでマッチデータオブジェクトを生成してメソッドに代入)

コントローラでparams[:datebegin], params[:dateend]をインスタンス変数にセット

コントローラでweekly, monthlyで場合分けしてReportクラスへ渡す

PERIODSに該当する場合はリダイレクト?

詰まった部分

app/controllers/statistics_controller.rbreport_by_dateアクションで、

GenerateReport::WeeklyReport.newした際にArgumentErrorが発生する。

Image from Gyazo

  • どのように実装してエラーが発生したのか?
#lib/generate_report/report.rb
module GenerateReport
  class Report
    def initialize(user:, column:, period: nil)
      @user = user
      @column = column
      @period = period ? period : nil 
    end
  end 
end


#lib/generate_report/monthly_report.rb
module GenerateReport
  class WeeklyReport < GenerateReport::Report

    attr_reader :column, :date_begin, :date_end

    def initialize(user:, column:, period: nil, date_begin: nil, date_end: nil)
      super(user, column, period)
      @date_begin = date_begin ? date_begin : generate_date_begin(period)
      @date_end = date_end ? date_end : date_begin.end_of_week
    end
    # 省略
  end 
end 

#StatisticsController
class StatisticsController < ApplicationController
  def report_by_date
    @COLUMNS = { activity: "活動量", mood: "気分", appetite: "食欲" }
    set_params_when_report_by_date_action(statistics_params)
    if is_date_weekly?(@date_begin, @date_end)
      @report = GenerateReport::WeeklyReport.new(user: current_user, column: @column, date_begin: @date_begin, date_end: @date_end)
    else
      @report = GenerateReport::MonthlyReport.new(user: current_user, column: @column, date_begin: @date_begin, date_end: @date_end)
    end
    binding.pry
  end
end 
  • ここで実現したいこと
    • statisticsコントローラのreport_by_periodアクションではWeeklyReport.newの引数がuser, column, period の3つ
    • 同コントローラのreport_by_dateアクションではWeeklyReport.newの引数がuser, column, date_begin, date_end の4つ
    • user, columnは両アクションに共通していて、period, date_begin, date_endは存在すれば代入し、無ければinitialize時に別途セットするようにしたい。
  • Report, WeeklyReportクラスの初期化時の引数を修正する
#lib/generate_report/report.rb
class Report
  attr_reader :column, :period, :date_begin, :date_end

  def initialize(user:, column:, period: nil, date_begin: nil, date_end: nil)
    @user = user
    @column = column
    @period = period ? period : nil
  end
  #省略
end 

#lib/generate_report/monthly_report.rb
class WeeklyReport < GenerateReport::Report

  attr_reader :column, :date_begin, :date_end

  def initialize(user:, column:, period: nil, date_begin: nil, date_end: nil)
    super
    @date_begin = date_begin ? date_begin : generate_date_begin(period)
    @date_end = date_end ? date_end : date_begin.end_of_week
  end
  #省略
end

/statistics/activity/prevWeekのリクエスト時

Image from Gyazo

/statistics/activity/from/2019-10-07/to/2019-10-13のリクエスト時

Image from Gyazo

WeeklyReportクラスでintialize時に引数が異なる場合でも, 共通の処理を提供できるようになった。

体調グラフ機能, 該当期間のy軸の値を吐き出すメソッドに例外処理を実装する(Rails, Ruby)

アプリ制作

実現したいこと(1)

Diaryレコードが存在しない期間を指定した際に、app/lib/generate_report/weekly_report.rbdata_generatorメソッドでエラーが発生してしまう。レコードが存在しない場合でも値を0としてグラフの描画を行えるようにしたい。

def data_generator(user, column, date_begin)
  records = user.diaries.group_by_week(week_start: :mon) { |n| n.diary_date }
  ## レコードが存在しない期間を指定すると, Hashで存在していないキー指定するためnilが返る
  ## nilの場合にエラー, 
  records[date_begin].map do |diary|
    diary.send(column)
  end
end

どのように実装するか?

records[date_begin]nilかどうか確かめ、nilの時は配列のnilの要素を7個作成して返す。

def data_generator(user, column, date_begin)
  records = user.diaries.group_by_week(week_start: :mon) { |n| n.diary_date }
  if recordes[date_begin].present?
    records[date_begin].map do |diary|
      diary.send(column)
    end
  elsif records[date_begin].nil?
    7.times.map { |n| nil }
  else
    raise "Error"
  end
end

手動でテスト

  • 今週(10/14-10/20)のレコードを削除し, 今週の体調グラフにアクセスし以下の項目を確認
    • エラーが発生しないか?
    • グラフの縦軸の値が何も表示されていないか?
  • テスト成功

実現したいこと(2)

statistics/:column/from/:date_begin/to/:date_endでグラフ描画をする

どのように実装するか?

lib下のDateConstraintクラスでURIの/:date_begin, /:date_endを Date.valid_date?メソッドで検証する(year, month, dayでマッチデータオブジェクトを生成してメソッドに代入)

コントローラでparams[:date_begin], params[:date_end]をインスタンス変数にセット

コントローラでweekly, monthlyで場合分けしてReportクラスへ渡す

PERIODSに該当する場合はリダイレクト?

その他に実装したこと

/statistics/:column/period/thisMonth(or prevMonth)でグラフ描画できるようにした。

プロを目指す人のためのRuby入門

  • 例外処理について

サービスクラスにグラフ描画に関する処理を移す

アプリ制作

昨日のエラー

Constraint::PeriodConstraintクラスで, /statistics/activity/period/thisWeekの"thisWeek"の部分が正しいURIになっているか検証するように実装した。

しかしno route matchedエラーが出てしまい、うまく作動していない。原因がわからなかった。

module Constraint
  class SelectPeriodConstraint
    PERIODS = ['thisWeek', 'thisMonth', 'prevWeek', 'prevMonth']

    def matches?(request)
      PERIODS.include?(request.query_parameters['period'])
    end
  end
end

binding.pryで確認してみるとrequest.query_parameters['week_or_month']nilになっていた。requestはActionDispatch::Requestクラスだと分かったので、ドキュメントを確認するとparamsメソッドでURIのセグメントにアクセスできると分かり、解決。

本日実装した機能

  • /statistics/:column/period/:periodで期間を指定してグラフを描画する機能
  • :periodセグメントをRouting時に検証する(RailsGuideのAdvanced Constraintsを使用)
  • Weeklyのグラフ描画時に前後1週間へのリンクを生成する機能
  • 表示しているカラム以外の、2つのカラムへのリンクを生成する機能
    • activity, mood, appetiteのうち、 activityを表示している時に残り2つのカラムへの, 同期間のリンクを生成する

Image from Gyazo

どのように実装したか?

サービスクラスとしてGenerateReport::Reportクラス, さらにReportクラスを継承するWeeklyReport, MonthlyReportクラスを作成した。

コントローラでparamsを解析してWeekyかMonthlyで条件分岐をして、それぞれに処理を移した。

それによって、コントローラの肥大化を抑えた。

  • Reportクラスの主な処理
    • リクエスURI:periodセグメントの文字列から始点, 終点の日付をセットする
    • それらの日付とカラムから各日付のカラムの値を取り出す
    • 前週、次週へのURI文字列を生成する
    • 同期間の別カラムへのURI文字列を生成する

参考にしたもの

[SimpleCalender](<https://github.com/excid3/simple_calendar)というgemのコードをGithubで観察した。

「Reportクラスを継承して、Weekly, Monthlyでクラスを継承する」というアイデアはこのgemからトレースしてみた。

このGemはRailsの深いところのメソッドをたくさん使っていて、理解できないところは多かった。

コードを観察したことで、Railsの仕組みを理解したい!みたいなモチベーションが生まれた。

(rails) SimpleCalenderGemのコードを読みながら、Report機能の処理を考え直す

  • SimpleCalenderのコードを読む

    • view_contextというViewクラスのインスタンスを操作していた
    • Calenderクラスを継承し、WeekCalender, MonthCalendeの2つのクラスが作られ処理を分けて書いていた。
  • params[:period]で条件分岐して、別のReportインスタンスを作成する(WeeklyReport, MonthlyReport)

  • それぞれのクラスでnext_period, prev_periodというメソッドを定義して、Viewからメソッドにアクセスできるようにする

  • SimpleCalender gem のコードを読もうとしたが、まだまだ分からない部分が多かった。プロを目指す人のためのRuby入門に書かれていたコードが多く出てきたが、理解しにくい。教科書と応用の差を感じた。

  • Railsの仕組みを少し知ることができた。(コントローラのインスタンス変数がViewでそのまま使えるのはなぜか?など)

class StatisticsController < ApplicationController
  before_action :require_user_logged_in, :set_current_user

  def report
    set_params(statistics_params)
    if period == :week
      @chart  = GenerateReport::Report::WeeklyReport.new(current_user, column, period, date_begin, date_end).call
    else period == :month
      @chart  = GenerateReport::Report::MonthlyReport.new(current_user, column, period, date_begin, date_end).call
    end
  end

private

  def set_params(params)
    column = params[:column].nil? ? :activity : params[:column].to_sym
    period = params[:period].nil? ? :week : params[:period].to_sym
    date_begin = params[:date_begin].nil? ? Date.today.begininng_of_week : params[:date_begin].to_date
    date_end = params[:date_end].nil? ? Date.today.end_of_week : params[:date_end].to_date
  end

  def statistics_params
    params.permit(:column, :date_begin, :date_end, :period).to_h
  end
end

(Rails) routingのcustom constraintsをlib直下のモジュールに定義する

アプリ制作

実現したいこと

体調グラフ機能で、クエリパラメータを使用せずにURI・コントローラを設計する。

取り組んだこと

  • TogglDashboardURIを観察してまとめる。

    • ルートは当日の週に設定
    • 当日の週or当月は /period/thisWeek(or Month)
    • 当日の前週or前月は/period/prevWeek(or Month)
    • それ以外の期間は/from/yyyy-mm-dd/to/yyyy-mm-dd
  • URIの設計を再度行う。

  /statistics => statistics#report 
  
  /statistics/:column/period/week or month => statistics#select_period
  
  /statistics/:column/from/yyyy-mm-dd/to/yyyy-mm-dd => select#select_date
    #=> key: 週の初めの日(Dateオブジェクト)
    #=> value: その週のレコードの配列
    group = diaries.group_by_week(series: true) { |n| n.diary_date }
    
    
    # 7月1週目の週のactivityカラムの集計をする
    date = Date.parse('2019-06-30', '%Y-%m-%d')
    
    group[date].map do |diary|
        diary.activity
    end
    #[-2, -1, 1, 0, -2, -2]
    ## コードめちゃ短くて済む!!
  • custom constraints の設定

    • URIのセグメントをバリデーションする処理をコントローラに記述していたが、コントローラが肥大化してしまう。routeのconstraintでそれを代わりに行うことで、コントローラを軽くする・
  # config/routes.rb
  Rails.application.routes.draw do
    
    get 'statistics/:column/period/:week_or_month', to: 'statistics#select_period', constraints: Constraint::SelectPeriodConstraint.new
    
    get 'statistics/:column/from/:date_begin/to/:date_end', to: 'statistics#select_date', constraints: Constraint::SelectDateConstraint.new
  end 
  
  # app/lib/constraint/select_date_constraint.rb
  module Constraint
    class SelectDateConstraint
      def matches?(request)
        date_regex = /\d{4}\-\d{1,2}\-\d{1,2}/
        date_regex.match(request.query_parameters['date_begin']) && date_regex.match(request.query_parameters['date_end'])
      end
    end
  end
  
  # app/lib/constraint/select_period_constraint.rb
  module Constraint
    class SelectPeriodConstraint
      PERIODS = ['thisWeek', 'thisMonth', 'prevWeek', 'prevMonth']
      
      def matches?(request)
        PERIODS.include?(request.query_parameters['week_or_month'])
      end
    end
  end

分からないこと

  • コントローラのbefore_actionで複数のメソッドを実行したい。1つのメソッドは引数を取る。

    • 下のようなことがしたい。これができるのかは分からない。Procとかまだやってない...
  class StatisticsController < ApplicationController
    before_action do
     require_user_logged_in,
     set_current_user,
     set_params(statistics_params)
    end
  # 省略
  end 
  • StatisticsControllerでReportを生成する処理をどうやって簡素に書くか?
    • 期間を表す単語(thisWeek, prevMonth等)で期間をしていする方法、date_begindate_endを直接指定する2つのやり方がある。
    • groupdate gem を使ってどう書けば良いか?
    • 重複の処理を書かないようにする。

プロを目指す人のためのRuby入門

  • 第9章 例外処理を理解する