アプリ内の日付変更線をズラす系の実装

例えば日記を書くときに、午前 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_dayall_day03: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'

であって欲しい。

時間は連続なので、切断点はどちらかのみに所属しなければならない=閉区間だと表現できないのだ。詳しくはデデキントカットを見てくれ。何故かニコニコ大百科が詳しい。

dic.nicovideo.jp

じゃあどう実装すれば良いかで言うと、終端を含まない ... で作られた 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