QA@IT

ruby での月の列挙

4117 PV

rails 4.0.0 で年月の選択に input type="month" だとブラウザごとの実装の違いが大きくて使いにくかったので、無難に select で実装したのですが、選択肢を生成する部分で Date.new(2013,1).step(Date.today, 1.month) のような感じで step を使おうとしてもうまくいかなかったので、 while ループにしてしまったのですが、何かもっと良い方法はないでしょうか?

  def self.year_month_collection(start_month=Date.new(2013, 1), end_month=Date.today)
    collection = [[nil, "未選択"]]
    month = start_month
    while month <= end_month
      collection << [month.strftime("%Y-%m"), format_month(month)]
      month >>= 1
    end
    collection
  end

回答

私は、年月を扱う場合 年 * 12 + 月 - 1 の値をよく利用します。
整数なので扱いやすく、Date への復元も Date.new(n / 12, n % 12 + 1) で可能です。

def year_month_collection(start_month=Date.new(2013, 1), end_month=Date.today)
  ((start_month.year * 12 + start_month.month - 1)..
   (end_month.year * 12 + end_month.month - 1)).map do |n|
    date = Date.new(n / 12, n % 12 + 1)
    [date.strftime("%Y-%m"), format_month(date)]
  end
end

コードの意図がわかりにくくなるので、何度も使う場合は "年月" を表すクラスを作ったほうがよいでしょうね。

class YearMonth
  def initialize(year, month)
    @code = year * 12 + month - 1
  end

  def year
    @code / 12
  end

  def month
    @code % 12 + 1
  end

  def to_i
    @code
  end

  def to_date
    Date.new(year, month)
  end

  def strftime(format)
    to_date.strftime(format)
  end

  # <=> と succ が実装されているので Range#each が使える。
  # (YearMonth.new(2013, 1)..YearMonth.new(2014, 1)).map{|ym| ym.strftime("%Y-%m") }
  def <=>(other)
    to_i <=> other.to_i
  end

  def succ
    n = @code + 1
    YearMonth.new(n / 12, n % 12 + 1)
  end
end
編集 履歴 (0)
  • y*100+m ではなく y*12+m-1 という発想はなかったので、機会があれば使ってみたいと思います。
    ありがとうございました。
    -

本題からそれますが、現状だと

start_month = Date.new(2013,1,2)
end_month = Date.new(2013,1,1)

の様に、end_monthが同月の過去日だった場合は月が現れませんね。
引数名を見る限り日は無視される印象ですので、railsなら .beginning_of_monthなどで月初の日にそろえるなどして使った方がいいかもしれません。
こういう仕様であれば問題ないですが一応指摘させていただきました。


それだけではなんですのでいくつか書いてみました。

月数を計算してtimes使ってみましたが、これはよくないですね。

def self.year_month_collection(start_month=Date.new(2013, 1), end_month=Date.today)
  collection = [[nil, "未選択"]]
  return collection unless start_month <= end_month
  num_of_months = (end_month.year - start_month.year) * 12 + (end_month.month - start_month.month) + 1
  num_of_months.times do |x|
    month = start_month + x.months
    collection << [month.strftime("%Y-%m"), format_month(month)]
  end
  collection 
end

while修飾子を使えば行を減らすことはできます。
m への代入もロジックとしては冗長ですので、その行を削除して start_monthを直接つかっても動きます
(個人的には start_monthの中身が変わって名前と合わなくなるのが嫌ですが)。
while修飾子の左辺を括弧でまとめてるのが気にならなければ。

def self.year_month_collection(start_month=Date.new(2013, 1), end_month=Date.today)
  collection = [[nil, "未選択"]]
  m = start_month
  (collection << [m.strftime("%Y-%m"), format_month(m)]; m >>= 1) while m <= end_month
  collection
end

変わり種(といいますかネタ)としては

start_month.step(end_month,1).select{ |d| d.day == 1 }

とか

limit = Date.new(end_month.year, end_month.month, 28)
start_month.step(limit,28).group_by{ |x| Date.new(x.year, y.month) }

で、月の配列を作れます。検証はあまりしてません。
最後のやつは28日ずつ進めることでループ回数を減らしてます。その都合でstepのlimit引数に渡すものを工夫してます。

編集 履歴 (3)
  • 開始日は今のところ固定なので、終了日より後になることはないと思って beginning_of_month などは不要と判断していました。
    指摘ありがとうございます。
    実装例の方はちょっと複雑すぎる気がするので、とりあえずは今のままにしておこうと思っています。
    -
ウォッチ

この質問への回答やコメントをメールでお知らせします。