例えば日記を書くときに、午前 2 時に書いたものは前日分としたいことがある。またユーザがメチャクチャ多いサービスでは、0:00 を回ったら翌日のログインボーナスを配る、としていると、まだユーザが多い時間にサーバの処理が要求されて大変なので、28:00 を日付変更線にしたいことがある。
こういうときには
module AppTime def self.beginning_of_day(time) t = time.change(hour: 4) t <= time ? t : t - 1.day end end
を作って、
AppTime.beginning_of_day(Time.current)
を使うと「アプリ内の日付変更線では何日なのか」が取れる。
# 02:00 は前日扱い time = Time.zone.parse("2021-01-31 02:00") AppTime.beginning_of_day(time) # => Sat, 30 Jan 2021 04:00:00.000000000 JST +09:00
僕は Date 苦手なので何でも Time で処理したい派だけど、こういうメソッドがあっても便利だと思う。
module AppTime def self.today to_date(Time.current) end def self.to_date(time) beginning_of_day(time).to_date end end
# 02:00 は前日扱い time = Time.zone.parse("2021-01-31 02:00") AppTime.to_date(time) => Sat, 30 Jan 2021
また、「今日日記を書いたか?」や「今日ログインボーナスを受け取ったか?」を以下のようなメソッドを用意することで表現できる。
module AppTime # AppTime.all_day(Time.current) は以下の Range を返す # => Sun, 31 Jan 2021 04:00:00.000000000 JST +09:00..Mon, 01 Feb 2021 03:59:59.999999999 JST +09:00 def self.all_day(time) beginning_of_day(time)..end_of_day(time) end def self.end_of_day(time) (beginning_of_day(time) + 1.day - 1.second).change(usec: Rational(999999999, 1000)) end end
class User < ApplicationRecord has_many :diaries end class Diary < ApplicationRecord belongs_to :user scope :of_day, ->(time) { where(published_at: AppTime.all_day(time)) } end
user.diaries.of_day(Time.current).exists? # Dairy Exists? (2.2ms) SELECT 1 AS one FROM `diaries` WHERE `diaries`.`user_id` = 1 AND `diaries`.`published_at` BETWEEN '2021-01-30 19:00:00' AND '2021-01-31 18:59:59' LIMIT 1
肝は「DB には UTC で保存して、アプリ内では JST で扱う、というのは変えない」「日付変更線に沿った Time や Range を返すモジュールを用意することで、保存時に 04:00 を入れるし、検索時には 04:00〜04:00 (UTC では 19:00〜19:00) で検索する」です。
アプリケーション側で変換するのだと限界もあって、日記だと Habit Tracker っぽい (いわゆる「草」) 見せ方をしたいし、その実装に groupdate gem を使うことが多いと思うけど、そういうのはアプリ内日付変更線に基づいた日付カラムを DB に入れてしまうと楽だと思います。
エクスキューズ
ここから始めてリファクタリングして育てていきましょう、です。 この記事ではモジュールにしたけど、クラスにしてアプリ内日付変更線を持ったインスタンスを作っていくこともあるだろう。
all_day
難しい
ActiveSupport の実装に寄せて end_of_day
や all_day
で 03:59:59.999999999
を返して書いたけど、時間を区切るというのは半開区間でないと困るんですよ。
↑↑でも
`diaries`.`published_at` BETWEEN '2021-01-30 19:00:00' AND '2021-01-31 18:59:59'
が SQL として見えてるけど、BETWEEN
では表現したくなくて、
WHERE '2021-01-30 19:00:00' <= published_at AND published_at < '2021-01-31 19:00:00'
であって欲しい。
時間は連続なので、切断点はどちらかのみに所属しなければならない=閉区間だと表現できないのだ。詳しくはデデキントカットを見てくれ。何故かニコニコ大百科が詳しい。
じゃあどう実装すれば良いかで言うと、終端を含まない ...
で作られた Range を渡すといい感じになります。
module AppTime def self.all_day(time) beginning_of_day(time)...beginning_of_day(time + 1.day) end end
user.diaries.of_day(Time.current).exists? # Diary Exists? (2.0ms) SELECT 1 AS one FROM `diaries` WHERE `diaries`.`user_id` = 1 AND `diaries`.`published_at` >= '2021-01-30 19:00:00' AND `diaries`.`published_at` < '2021-01-31 19:00:00' LIMIT 1