こんにちは!
ねこじょーかー(@nekojoker1234)と申します。
プログラミングをしていると、急にランキング機能をつくりたくなるときはありませんか?
私はあります。
ということで、Railsでランキング機能を作ったのですが、結構ゴリゴリな感じになってしまいました。
あまり参考にならないと思いますが、せっかくつくったので、ほんの少しでも誰かの参考になればと思い、ここに残しておきます。
ちなみに、私が作ったLookmineというWebサービスで動作を確認できるので、よければご覧ください。
##前提
データ構造は、以下のとおりになっています。
Postモデル
id | user_id | study_date |
---|
Itemモデル
id | post_id | study_hour | study_minutes |
---|
Postは、複数のItemを持つとします。
イメージ図は以下の感じです。
上記のような投稿をユーザーごとに集計して、時間の多い順からランキングを作っていきます。
##最終的にできるもの
最終的にできるものはこんな感じです。
「<」で前月、「>」で次月のランキングをチェックできるようにします。
##機能概要
- 上位100人をランキングに表示する
- 自分が何人中何位なのかわかるようにする
- 前月と次月のランキングも確認できるようにする
- 合計時間が同じ人は同じ順位とする
- 月間で集計する
##Controllerの作成
グラフ作成用のコントローラーを作成します。
$ rails g controller charts
今回は月別のランキングを作成するので、ルーティングに以下の内容を追加します。
get 'charts/monthly'
まずは、ロジック部分を書いていきましょう。
コントローラーにmonthly
メソッドを追加して、ここに追記していきます。
def monthly
end
まず、日付の集計範囲を決めます。
if params[:pre_preview].present?
@target_month = Date.parse(params[:pre_preview]) << 1
elsif params[:next_preview].present?
@target_month = Date.parse(params[:next_preview]) >> 1
else
@target_month = Time.current.to_date << 1
end
上記を日本語で書くと、以下のようになります。
-
pre_preview
のパラメータが入っていれば、1ヶ月"前"を@target_month
に入れる -
next_preview
のパラメータが入っていれば、1ヶ月"後"を@target_month
に入れる - それ以外は、現在の日付の1ヶ月"前"を
@target_month
に入れる
これだけだとわかりにくいので、もっとわかりやすく書いてみます。
- 「<」を選択したら、1ヶ月前の日付をセット
- 「>」を選択したら、1ヶ月後の日付をセット
- 初期表示のときは、1ヶ月前の日付をセット
どうでしょうか。イメージが湧いてきましたか?
この日付をもとに、データ集計をしていきます。
ちなみに、このパラメータはview
側から渡します。
次に、実際にランキングを集計してくるSQLを組み立てますが、ここが結構やっかいなところです。
# カテゴリ、ユーザーID、1ヶ月分で絞り込み、総勉強時間(h)と(m)の合計を取得
@ranks = Post.joins(:items)
.select("posts.user_id
,sum(items.study_hour) as study_hour
,sum(items.study_minutes) as study_minutes
,to_char(posts.study_date,'YYYY') as study_year
,to_char(posts.study_date,'MM') as study_month
,round(sum(items.study_hour) + (cast(sum(items.study_minutes) as decimal) / 60),2) as study_total
,RANK () OVER (PARTITION BY to_char(posts.study_date,'YYYY'),to_char(posts.study_date,'MM') order by (sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc) as rank_number")
.where(study_date: @target_month.all_month)
.group("to_char(posts.study_date,'YYYY')
,to_char(posts.study_date,'MM')
,posts.user_id")
.order("(sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc")
「なんのこっちゃ」と思う人がほとんどだと思うので、分解して解説していきます。
Post.joins(:items)
joins
にitems
を指定すると、列の名前から勝手に判断して、テーブル結合をしてくれます。
study_hour
とstudy_minutes
を取ってくるのに、必要な処理です。
.select("posts.user_id
,sum(items.study_hour) as study_hour
,sum(items.study_minutes) as study_minutes
,to_char(posts.study_date,'YYYY') as study_year
,to_char(posts.study_date,'MM') as study_month
,round(sum(items.study_hour) + (cast(sum(items.study_minutes) as decimal) / 60),2) as study_total
,RANK () OVER (PARTITION BY to_char(posts.study_date,'YYYY'),to_char(posts.study_date,'MM') order by (sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc) as rank_number")
次はselect
の部分です。
study_hour
とstudy_minutes
は合計時間を集計したいので、sum
をします。
sum
をするには、集約のためにgroup
が必要になるので、user_id
と年(YYYY)と月(MM)を集約キーとして追加しています。
たとえば、「2019年、8月、Aさん」という条件で集計してくるイメージですね。
study_total
は勉強時間(h)の合計が知りたいので、study_minutes
(分)を時間(h)に変換して足し算しています。
rank_number
のところは長いですが、意味としては「年、月、人で勉強時間を集計して、1番から番号を振る」という動きになっています。
これで、集計に必要な要素は揃いました。
.where(study_date: @target_month.all_month)
これは、Postモデルのstudy_date
を対象にして、先ほど取っておいた@target_month
を含む1ヶ月間を集計してくる、という条件です。
たとえば、@target_month
が「2019/9/3」であれば、「2019/9/1〜2019/9/30」が条件として追加されます。
.order("(sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc")
これは勉強時間の多い順から並べるという意味ですね。
長くなりましたが、ランキング機能のSQLとしてはこんな感じです。
次は、自分の順位をわかるようにするための計算をしていきます。
@my_rank = 0
@ranks.each do |rank|
if rank.user_id == current_user.id
@my_rank = rank.rank_number
break
end
end
先ほどゴリゴリと書いたSQLをループで回して、自分のデータの場合、ランクの数字を変数に入れています。
これを、view
の方で参照して表示します。
##最終的に出来上がるもの(Controller)
いろいろと書きましたが、最終的には以下の内容でメソッドが出来上がります。
def monthly
# 月ごとユーザーごとで勉強時間を集計
# 集計時間の降順で並べ替え
if params[:pre_preview].present?
@target_month = Date.parse(params[:pre_preview]) << 1
elsif params[:next_preview].present?
@target_month = Date.parse(params[:next_preview]) >> 1
else
@target_month = Time.current.to_date << 1
end
# カテゴリ、ユーザーID、1ヶ月分で絞り込み、総勉強時間(h)と(m)の合計を取得
@ranks = Post.joins(:items)
.select("posts.user_id
,sum(items.study_hour) as study_hour
,sum(items.study_minutes) as study_minutes
,to_char(posts.study_date,'YYYY') as study_year
,to_char(posts.study_date,'MM') as study_month
,round(sum(items.study_hour) + (cast(sum(items.study_minutes) as decimal) / 60),2) as study_total
,RANK () OVER (PARTITION BY to_char(posts.study_date,'YYYY'),to_char(posts.study_date,'MM') order by (sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc) as rank_number")
.where(study_date: @target_month.all_month)
.group("to_char(posts.study_date,'YYYY')
,to_char(posts.study_date,'MM')
,posts.user_id")
.order("(sum(items.study_hour) + cast(sum(items.study_minutes)as decimal) / 60) desc")
@my_rank = 0
@ranks.each do |rank|
if rank.user_id == current_user.id
@my_rank = rank.rank_number
break
end
end
end
##viewの作成
slimだったりbootstrapだったりするので、ご了承ください。
長いので、ソース上にコメントを書いていきますね。
/ メインタイトルは、controllerの変数を参照して動的に作成
- @page_title = "ランキング【#{@target_month.year.to_s[2,2]}年#{@target_month.month}月度】"
.col-md-6.mx-auto
.form-inline.mb-3.justify-content-around
/ 「<」ボタンの作成(アイコンはfontawesomeを使用) controllerにパラメータを渡す
= link_to "", charts_monthly_path(:pre_preview => @target_month), class: "fa fa-chevron-left mr-3"
span.ml-2.mr-2.lead
strong
= @page_title
/ 「>」ボタンの作成(アイコンはfontawesomeを使用) controllerにパラメータを渡す
= link_to "", charts_monthly_path(:next_preview => @target_month), class: "fa fa-chevron-right ml-3" </script>
.text-center
/ もし期間中に自分の実績があれば、順位を表示する
- if @my_rank == 0
= 'あなたはこの期間の実績がありません'
- else
= "あなたの順位は #{@ranks.size}人中"
strong
= " #{@my_rank}位 "
= "です"
/ ここからテーブル
table.table.table-hover.table-bordered
thead.thead-light
tr.text-center
th = "順位"
th = "ユーザー名"
th = "合計(h)"
tbody
/ ランキングは100位までとし、100位までループ
- @ranks.limit(100).each_with_index do |rank,i|
tr
td.text-center
/ 1位〜3位の場合は、順位に王冠を表示する
- if rank.rank_number != 1 && rank.rank_number != 2 && rank.rank_number != 3
= rank.rank_number
= rank_image(rank.rank_number)
.form-inline
- user = User.find(rank.user.id)
td = link_to(user_path(user)) do
= image_tag avatar_url(user).to_s
= user.name
td.text-right
/ 見栄え的に、小数点以下を2桁で揃えておく
= format("%.2f",rank.study_total)
##ついに完成
/charts/monthly
にアクセスすると、以下の画面が表示されます。
お疲れさまでした!
あわせて読みたい
HTMLもわからない初心者が、独学で「投稿型SNSサービス」を作ったって本当?【193日間の死闘】
運営している PlayFab 専用ブログ
https://playfab-master.com