LoginSignup
13
12

More than 3 years have passed since last update.

【Rails】世界一参考にならない「ランキング機能」をゴリゴリ実装する方法

Last updated at Posted at 2019-09-03

こんにちは!
ねこじょーかー(@nekojoker1234)と申します。

プログラミングをしていると、急にランキング機能をつくりたくなるときはありませんか?
私はあります。

ということで、Railsでランキング機能を作ったのですが、結構ゴリゴリな感じになってしまいました。

あまり参考にならないと思いますが、せっかくつくったので、ほんの少しでも誰かの参考になればと思い、ここに残しておきます。

ちなみに、私が作ったLookmineというWebサービスで動作を確認できるので、よければご覧ください。

前提

データ構造は、以下のとおりになっています。

Postモデル

id user_id study_date

Itemモデル

id post_id study_hour study_minutes

Postは、複数のItemを持つとします。
イメージ図は以下の感じです。

スクリーンショット 2019-09-02 20.58.51.png

上記のような投稿をユーザーごとに集計して、時間の多い順からランキングを作っていきます。

最終的にできるもの

最終的にできるものはこんな感じです。
「<」で前月、「>」で次月のランキングをチェックできるようにします。
ランキング機能

機能概要

  • 上位100人をランキングに表示する
  • 自分が何人中何位なのかわかるようにする
  • 前月と次月のランキングも確認できるようにする
  • 合計時間が同じ人は同じ順位とする
  • 月間で集計する

Controllerの作成

グラフ作成用のコントローラーを作成します。

$ rails g controller charts

今回は月別のランキングを作成するので、ルーティングに以下の内容を追加します。

config/routes.rb
get 'charts/monthly'

まずは、ロジック部分を書いていきましょう。
コントローラーにmonthlyメソッドを追加して、ここに追記していきます。

app/controllers/charts_controller.rb
def monthly
end

まず、日付の集計範囲を決めます。

app/controllers/charts_controller.rb
    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を組み立てますが、ここが結構やっかいなところです。

app/controllers/charts_controller.rb
    # カテゴリ、ユーザー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)

joinsitemsを指定すると、列の名前から勝手に判断して、テーブル結合をしてくれます。
study_hourstudy_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_hourstudy_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としてはこんな感じです。

次は、自分の順位をわかるようにするための計算をしていきます。

app/controllers/charts_controller.rb
@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だったりするので、ご了承ください。
長いので、ソース上にコメントを書いていきますね。

app/views/charts/monthly.html.slim
/ メインタイトルは、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

13
12
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
13
12