はじめに
現在、Ruby on Railsにて睡眠を記録するアプリを作成しています。睡眠の平均時間を表示しようとしたら、一筋縄ではいかず苦労しました。対策を備忘録として残そうと思います。
開発環境
Mac OS Big Sur 11.6
VSCode
Ruby 3.0.2p107
Rails 6.1.4.1
MySQL 8.0.27 for macos11.6 on arm64 (Homebrew)
データ
DB(MySQL)ではSleepLogsテーブルを作成し、以下のデータを保存しています。
sleep_atカラムやwake_atカラムは、データの表示や睡眠時間の計算を行うため、datetime型で保存しています。
項目名 | 値の例(値の型) | 説明 |
---|---|---|
:user_id | 1(bigint) | userの外部キー |
:sleep_at | Thu, 02 Dec 2021 03:58:31.000000000 JST +09:00(datetime) | 就寝日時 |
:wake_at | Thu, 02 Dec 2021 09:17:00.000000000 JST +09:00(datetime) | 起床日時 |
:satisfaction | 1(bigint) | 睡眠の満足度 |
やりたいこと
seedを使い、起床時間がランダムなデータを20件ほど作成しています。保存されている日時データから平均を算出し、平均の起床時間など__時間のみのデータ__を表示したいと考えました。画像では完成していますが、平均の起床時間に焦点をあてて、失敗例と成功例を交えつつ紹介します。
(特にCSSを当てていないため見にくいです。申し訳ありません。)
averageを使えばできるのでは?
では、平均起床時間の表示に挑戦します。Railsには指定したカラムの平均を求めるaverageメソッドがあるみたいです。__「これでwake_atカラムの平均を求めればできるのでは〜〜??(^^)」__と思ったのですが、できませんでした。
def show
# 現在のユーザーの記録のみ取得
@sleep_logs = SleepLog.where(user_id: params[:id])
@average = @sleep_logs.average(:wake_at)
end
viewファイルについては、以降省略します。
<%= @average %>
# => 20211211314945.0
そもそものデータについて考える
wake_atカラムのデータを1件取り出してみます。
def show
@average = @sleep_logs.first.wake_at
end
# => 2021-12-21 10:27:00 +0900
datetime型で保存されているため、起床時間だけではなく、起床した日付も保存されています。また、その中身は起算時からの経過秒数を保持しています。そのため、__各レコードから時間と分のみを取り出さなければならない__と考えました。
時間と分を取り出す
こちらの記事を参考にしました。 Time型カラムに記録された時間インスタンスの、平均時刻を取得する(日付は無視)
def show
# 現在のユーザーの記録のみ取得
@sleep_logs = SleepLog.where(user_id: params[:id])
# timesに時間と分のみを配列として記録
times = @sleep_logs.pluck(:wake_at).map { |log| log.hour * 60 + log.min }
# sumメソッドで合計を求め、sizeメソッドの件数で平均を出す
time_average = times.sum / times.size
# formatで出力の表示を調整。time_averageは単位が分なので、時間と分に変換する。
hour = time_average / 60
min = time_average % 60
@average = format('%02<hour>d:%02<min>d', hour: hour, min: min)
end
# => 09:35
とりあえず、これで平均を出力できました。(この平均には誤差があります。@scivolaさま、ありがとうございました。)
showメソッド内をトレースしながら、何をしているか紹介します。
現在のユーザーの記録のみ取得
whereを使用して、現在のユーザーが該当する記録を取得しています。
@sleep_logs = SleepLog.where(user_id: params[:id])
コンソールで確認すると中身はこんな感じです。
[#<SleepLog:0x00000001526123c8
id: 20,
user_id: 1,
sleep_at: Tue, 21 Dec 2021 03:58:31.000000000 JST +09:00,
wake_at: Tue, 21 Dec 2021 10:27:00.000000000 JST +09:00,
created_at: Wed, 22 Dec 2021 11:58:31.545341000 JST +09:00,
updated_at: Wed, 22 Dec 2021 11:58:31.545341000 JST +09:00,
satisfaction: 3>,
#<SleepLog:0x0000000152612058
id: 19,
user_id: 1,
sleep_at: Mon, 20 Dec 2021 03:58:31.000000000 JST +09:00,
wake_at: Mon, 20 Dec 2021 08:02:00.000000000 JST +09:00,
created_at: Wed, 22 Dec 2021 11:58:31.542132000 JST +09:00,
updated_at: Wed, 22 Dec 2021 11:58:31.542132000 JST +09:00,
satisfaction: 1>,
(省略)
timesに時間と分のみを配列として記録
取得したデータ全体の内、必要としているwake_atカラムのみを抽出。その後、mapメソッドで各データの時間と分のみ取り出して、配列で保存しています。
times = @sleep_logs.pluck(:wake_at).map { |log| log.hour * 60 + log.min }
# => [627, 482, 662, 492, 662, 544, 680, 552, 564, 541, 491, 565, 553, 622, 507, 662, 617, 629, 500, 557]
sumメソッドで合計を求め、sizeメソッドの件数で平均を出す
配列の合計を求め、配列の要素数で割り平均を求めます。
injectメソッドを使う方法もあるみたいですが、sumの方が早いみたいですね。
[Ruby]いろんな処理のベンチマーク選手権
time_average = times.sum / times.size
# => 575
[追記]除算の誤差について
今回は多少の誤差を許容しています。正しい平均を求める方法について@scivolaさまからご紹介いただきました
【Ruby のまずいコード】除算の落とし穴
formatで出力の表示を調整
配列の合計を求め、配列の要素数で割り平均を求めます。
formatについてはこちらを参考にしました。
Rubyのformatメソッドで名前付きのフォーマットを使う
hour = time_average / 60
# => 9
min = time_average % 60
# => 35
@average = format('%02<hour>d:%02<min>d', hour: hour, min: min)
# => "09:35"
参考にした記事
Time型カラムに記録された時間インスタンスの、平均時刻を取得する(日付は無視)
[Ruby]いろんな処理のベンチマーク選手権
Rubyのformatメソッドで名前付きのフォーマットを使う