🕟

【JavaScript】次の〇日、毎月〇日を日付処理する

2023/03/17に公開

実務では非常によく遭遇するパターンなのですが有名どころの日付ライブラリに実装されていない(たぶん)機能として、次の〇日毎月〇日というのがあります。

例えば、こんなことをしたいときに使う機能です。

  • カレンダーを翌月に切り替えたい
  • 毎月15日をサブスクの決済日にしたい
  • 翌月末までの予定を自動作成したい
  • 月を変えながらスクレイピングしたい

こういった処理は、その場で考えて実装するとミスに陥りがちです。決済日が1日ずれたとか、毎月のはずが1ヶ月すっぽかしたとか。

しっかり考えたうえで、Dateオブジェクト、date-fns、Day.jsで処理する方法をまとめてみました。

なぜ難しいのか

この処理がなぜ難しいのか、整理してみます。

ひと月の日数が月よって異なるから

主な理由は簡単でひと月の日数が月によって異なるからです。2月なんかはうるう年も影響してきます。

『次の30日の日付を取得する関数』を作ったとすると、その30日が月末を意味しているのか、月末の意味はなく常に30日なのか、区別がつきません。

例えば今日が4月30日だったとして、次の30日は5月30日なのか、5月31日なのか、どちらでしょうか。

月をまたぐときと、またがないときがあるから

『次の15日』と言ったときに、ベースが4月5日だったら4月15日になりますが、4月25日だったら5月15日になります。

ベースが何日なのかによって、次の15日の月が変わってしまいます。

さらに、『次の15日の9時』が欲しいときに、ベースが4月15日の8時だったとすると4月15日の9時になり、ベースが4月15日の10時だったとすると5月15日の9時になります。

さらにさらに、ベースが4月30日だったとします。このとき『次の月末』が欲しいとします。普通に考えれば5月31日になると思うかもしれません。
しかし、月末の処理をしたいとき、ベースが毎月30日としての4月30日だった場合は、まだ月末としての4月30日を迎えていませんので、『次の31日』は4月30日にならないといけないという状況もあり得ます。

うまく処理するためのポイント

このような複雑な状況の処理を行うために、必要な情報、気を付けるポイントがあります。

  1. 今月の〇日か翌月の〇日かの2択に持ち込む
  2. 次の〇日という情報は常に保持する
  3. 次の〇日の何時が必要なのか明確に決める
  4. 日付を求めるときは今月1日00:00:00から計算する
  5. 計算を行う単位(日なのか、秒なのかなど)を明確に決める

一番大事なのが、1番の2択に持ち込むことです。『次の15日』と言ったときに、今月の15日か、翌月の15日か、必ずどちらかにしかなりません。

このとき、今月と翌月のそれぞれの具体的な日時を求めるために2番3番4番が重要となります。

そして、どちらに該当するのか決めるために5番が重要となります。

具体的な処理

簡単なケースから、徐々に複雑なケースまで処理方法を書いていきます。

次の15日9時を取得する

最も簡単なケースです。

  • 月末が絡まない
  • 一回のみの計算
  • 計算を行う単位はJavaScriptの最小単位であるミリ秒

この時点で数行になりますので、一筋縄ではいかないのが分かります。日付の計算は大きい単位から実行しないとずれるので注意が必要です。年→月→日→時→分→秒

Date
  let date = new Date() // 今日をベースとする

  let baseDate = new Date(date.getFullYear(), date.getMonth()) // 計算の基準
  let dateOfThisMonth = new Date(baseDate) // クローンを作成
  dateOfThisMonth.setDate(15) // 今月の15日
  dateOfThisMonth.setHours(9) // 今月の15日9時
  let dateOfNextMonth = new Date(baseDate) // クローンを作成
  dateOfNextMonth.setMonth(dateOfNextMonth.getMonth() + 1) // 翌月
  dateOfNextMonth.setDate(15) // 翌月の15日
  dateOfNextMonth.setHours(9) // 翌月の15日9時

  // 今日が今月の15日9時より前の場合は今月、そうでなければ来月
  let nextDate = date < dateOfThisMonth ? dateOfThisMonth : dateOfNextMonth
date-fns
  let date = new Date() // 今日をベースとする
  let baseDate = startOfMonth(date) // 計算の基準
  let dateOfThisMonth = set(baseDate, { date: 15, hours: 9 }) // 今月の15日9時
  let dateOfNextMonth = set(add(baseDate, { months: 1 }), { date: 15, hours: 9 }) // 翌月の15日9時にする

  // 今日が今月の15日9時より前の場合は今月、そうでなければ来月
  let nextDate = date < dateOfThisMonth ? dateOfThisMonth : dateOfNextMonth
Day.js
  let date = dayjs() // 今日をベースとする
  let baseDate = date.startOf('month') // 計算の基準
  let dateOfThisMonth = baseDate.set('date', 15).set('hours', 9) // 今月の15日9時
  let dateOfNextMonth = baseDate.add(1, 'month').set('date', 15).set('hours', 9) // 翌月の15日9時にする

  // 今日が今月の15日9時より前の場合は今月、そうでなければ来月
  let nextDate = date.isBefore(dateOfThisMonth) ? dateOfThisMonth : dateOfNextMonth

次の〇日〇時を取得する

やっかいな問題となる月末の処理を行います。

  • 月末が絡む
  • 一回のみの計算
  • 計算を行う単位はJavaScriptの最小単位であるミリ秒

月末を適切に扱う方法としては、31日と該当月の最終日(=日数)の少ないほうを採用することです。
例えば、2月の28日が最終日だったとすると、Math.min(31, 28)で28日を採用します。

この方法だと月末だけでなく、毎月30日にしたいが2月だけは月末、ということもできます。月末かどうかに関わらず確実に次の〇日を取得できます。

月の最終日(=日数)の取得方法は利用するライブラリによります。

ついでに時間も任意にしてみます。

(diffのハイライトを使っていますが削除した行は省略しています)

Date
   let date = new Date() // 今日をベースとする
+  let days = 31 // 取得したい日
+  let hours = 9 // 取得したい時間

   let baseDate = new Date(date.getFullYear(), date.getMonth()) // 計算の基準

   let dateOfThisMonth = new Date(baseDate) // クローンを作成
+  let endOfThisMonth = new Date(dateOfThisMonth.getFullYear(), dateOfThisMonth.getMonth() + 1, 0) // 今月の末日
+  dateOfThisMonth.setDate(Math.min(days, endOfThisMonth.getDate())) // 今月の〇日
   dateOfThisMonth.setHours(hours) // 今月の〇日〇時

   let dateOfNextMonth = new Date(baseDate) // クローンを作成
   dateOfNextMonth.setMonth(dateOfNextMonth.getMonth() + 1) // 翌月
+  let endOfNextMonth = new Date(dateOfNextMonth.getFullYear(), dateOfNextMonth.getMonth() + 1, 0) // 翌月の末日
+  dateOfNextMonth.setDate(Math.min(days, endOfNextMonth.getDate())) // 翌月の〇日
   dateOfNextMonth.setHours(hours) // 翌月の〇日〇時

   // 今日が今月の〇日〇時より前の場合は今月、そうでなければ来月
   let nextDate = date < dateOfThisMonth ? dateOfThisMonth : dateOfNextMonth
date-fns
   let date = new Date() // 今日をベースとする
+  let days = 31 // 取得したい日
+  let hours = 9 // 取得したい時間

   let baseDate = startOfMonth(date) // 計算の基準
  
+  let dateOfThisMonth = set(baseDate, { date: Math.min(days, getDaysInMonth(baseDate)), hours }) // 今月の〇日〇時
+  let firstOfNextMonth = add(baseDate, { months: 1 }) // 翌月の1日
+  let dateOfNextMonth = set(firstOfNextMonth, { date: Math.min(days, getDaysInMonth(firstOfNextMonth)), hours }) // 翌月の〇日〇時にする

   // 今日が今月の〇日〇時より前の場合は今月、そうでなければ来月
   let nextDate = date < dateOfThisMonth ? dateOfThisMonth : dateOfNextMonth
Day.js
   let date = dayjs() // 今日をベースとする
+  let days = 31 // 取得したい日
+  let hours = 9 // 取得したい時間

   let baseDate = date.startOf('month') // 計算の基準
+  let dateOfThisMonth = baseDate.set('date', Math.min(days, baseDate.daysInMonth())).set('hours', hours) // 今月の〇日〇時
+ let firstOfNextMonth = baseDate.add(1, 'month')
+ let dateOfNextMonth = firstOfNextMonth.set('date', Math.min(days, firstOfNextMonth.daysInMonth())).set('hours', hours) // 翌月の〇日〇時にする

   // 今日が今月の〇日〇時より前の場合は今月、そうでなければ来月
   let nextDate = date.isBefore(dateOfThisMonth) ? dateOfThisMonth : dateOfNextMonth

毎月〇日〇時を取得する

これまで作ったものを関数化すれば、適用するたびに毎月の〇日〇時を取得できます。

  • 月末が絡む
  • 毎月の計算
  • 計算を行う単位はJavaScriptの最小単位であるミリ秒

関数の引数に必要なのは日付〇日〇時の3つです。

日付は、いつから次の日を取得するかの計算に必要です。

〇日については、もし日付と同じ日であれば必要ないというわけにはいきません。日付には月末かどうかの情報が欠如しているため、別途〇日が必要になります。

〇時に関しては、もし固定であればハードコーディングしてしまってもいいかもしれません。もし分や秒が必要な場合は関数の拡張が必要です。

Date
+ function getNextDate(date, days, hours) {
    let baseDate = new Date(date.getFullYear(), date.getMonth()) // 計算の基準

    let dateOfThisMonth = new Date(baseDate) // クローンを作成
    let endOfThisMonth = new Date(dateOfThisMonth.getFullYear(), dateOfThisMonth.getMonth() + 1, 0) // 今月の末日
    dateOfThisMonth.setDate(Math.min(days, endOfThisMonth.getDate())) // 今月の〇日
    dateOfThisMonth.setHours(hours) // 今月の〇日〇時

    let dateOfNextMonth = new Date(baseDate) // クローンを作成
    dateOfNextMonth.setMonth(dateOfNextMonth.getMonth() + 1) // 翌月
    let endOfNextMonth = new Date(dateOfNextMonth.getFullYear(), dateOfNextMonth.getMonth() + 1, 0) // 翌月の末日
    dateOfNextMonth.setDate(Math.min(days, endOfNextMonth.getDate())) // 翌月の〇日
    dateOfNextMonth.setHours(hours) // 翌月の〇日〇時

    // 今日が今月の〇日〇時より前の場合は今月、そうでなければ来月
    return date < dateOfThisMonth ? dateOfThisMonth : dateOfNextMonth
+ }

+ let date = new Date() // 今日をベースとする
+ for (let i = 0; i < 12; i++) {
+   date = getNextDate(date, 31, 9)
+   console.log(`${date.getFullYear()}${date.getMonth()+1}${date.getDate()}${date.getHours()}${date.getMinutes()}${date.getSeconds()}`)
+ }
date-fns
+ function getNextDate(date, days, hours) {
    let baseDate = startOfMonth(date) // 計算の基準
    let dateOfThisMonth = set(baseDate, { date: Math.min(days, getDaysInMonth(baseDate)), hours }) // 今月の〇日9時
    let firstOfNextMonth = add(baseDate, { months: 1 }) // 翌月の1日
    let dateOfNextMonth = set(firstOfNextMonth, { date: Math.min(days, getDaysInMonth(firstOfNextMonth)), hours }) // 翌月の〇日9時にする

    // 今日が今月の〇日9時より前の場合は今月、そうでなければ来月
    return date < dateOfThisMonth ? dateOfThisMonth : dateOfNextMonth
+ }

+ let date = new Date() // 今日をベースとする
+ for (let i = 0; i < 12; i++) {
+   date = getNextDate(date, 31, 9)
+   console.log(format(date, 'yyyy年M月d日H時m分s秒'))
+ }
Day.js
+ function getNextDate(date, days, hours) {
    let baseDate = date.startOf('month') // 計算の基準
    let dateOfThisMonth = baseDate.set('date', Math.min(days, baseDate.daysInMonth())).set('hours', hours) // 今月の〇日〇時
    let firstOfNextMonth = baseDate.add(1, 'month')
    let dateOfNextMonth = firstOfNextMonth.set('date', Math.min(days, firstOfNextMonth.daysInMonth())).set('hours', hours) // 翌月の〇日〇時にする

    // 今日が今月の〇日〇時より前の場合は今月、そうでなければ来月
    return date.isBefore(dateOfThisMonth) ? dateOfThisMonth : dateOfNextMonth
+ }

+ let date = dayjs() // 今日をベースとする
+ for (let i = 0; i < 12; i++) {
+   date = getNextDate(date, 31, 9)
+   console.log(date.format('YYYY年M月D日H時m分s秒'))
+ }

毎月〇日〇時を月や日単位で計算して取得する

今まで計算の単位をミリ秒にしていましたが、そうとは限らない場合があります。

  • 月末が絡む
  • 毎月の計算
  • 計算を行う単位は月や日など

いったいどんな状況のことを言っているのかというと、例えば毎月10日がサブスクの決済日だとします。
決済日には前月分の利用料が決済されるとします。すると、4月1日に初めて申し込んだ人の最初の決済日が4月10日だと、支払う料金が無いことになります。よって最初の決済日は5月10日であるべきです。

これは決済時間でも言えることで、利用日数で課金される場合に、毎月10日9時が決済日時だったとすると、4月10日0時に申し込んだ人はまだ1日使っていないため、5月10日が決済日であるべきです。

ミリ秒単位で課金されるサービスよりも、月単位、日単位、時単位で課金されるサービスのほうが世の中には多いのです。

そんなときは、計算するときの単位を揃えてあげると上手くいきます。単位を月に揃える場合は次のようになります。

Date
 function getNextDate(date, days, hours) {
   let baseDate = new Date(date.getFullYear(), date.getMonth()) // 計算の基準

   let dateOfThisMonth = new Date(baseDate) // クローンを作成
   let endOfThisMonth = new Date(dateOfThisMonth.getFullYear(), dateOfThisMonth.getMonth() + 1, 0) // 今月の末日
   dateOfThisMonth.setDate(Math.min(days, endOfThisMonth.getDate())) // 今月の〇日
   dateOfThisMonth.setHours(hours) // 今月の〇日〇時

   let dateOfNextMonth = new Date(baseDate) // クローンを作成
   dateOfNextMonth.setMonth(dateOfNextMonth.getMonth() + 1) // 翌月
   let endOfNextMonth = new Date(dateOfNextMonth.getFullYear(), dateOfNextMonth.getMonth() + 1, 0) // 翌月の末日
   dateOfNextMonth.setDate(Math.min(days, endOfNextMonth.getDate())) // 翌月の〇日
   dateOfNextMonth.setHours(hours) // 翌月の〇日〇時

+  let unitDate = new Date(date.getFullYear(), date.getMonth()) // 単位を月に揃える
+  let unitDateOfThisMonth = new Date(dateOfThisMonth.getFullYear(), dateOfThisMonth.getMonth()) // 単位を月に揃える

    // 今日の月が今月の〇日〇時の月より前の場合は今月、そうでなければ来月
+   return unitDate < unitDateOfThisMonth ? dateOfThisMonth : dateOfNextMonth
  }

  let date = new Date() // 今日をベースとする
  for (let i = 0; i < 12; i++) {
    date = getNextDate(date, 31, 9)
    console.log(`${date.getFullYear()}${date.getMonth()+1}${date.getDate()}${date.getHours()}${date.getMinutes()}${date.getSeconds()}`)
  }
date-fns
 function getNextDate(date, days, hours) {
   let baseDate = startOfMonth(date) // 計算の基準
   let dateOfThisMonth = set(baseDate, { date: Math.min(days, getDaysInMonth(baseDate)), hours }) // 今月の〇日9時
   let firstOfNextMonth = add(baseDate, { months: 1 }) // 翌月の1日
   let dateOfNextMonth = set(firstOfNextMonth, { date: Math.min(days, getDaysInMonth(firstOfNextMonth)), hours }) // 翌月の〇日9時にする

   // 今日の月が今月の〇日〇時の月より前の場合は今月、そうでなければ来月
+  return startOfMonth(date) < startOfMonth(dateOfThisMonth) ? dateOfThisMonth : dateOfNextMonth
 }

 let date = new Date() // 今日をベースとする
 for (let i = 0; i < 12; i++) {
   date = getNextDate(date, 31, 9)
   console.log(format(date, 'yyyy年M月d日H時m分s秒'))
 }
Day.js
 function getNextDate(date, days, hours) {
   let baseDate = date.startOf('month') // 計算の基準
   let dateOfThisMonth = baseDate.set('date', Math.min(days, baseDate.daysInMonth())).set('hours', hours) // 今月の〇日〇時
   let firstOfNextMonth = baseDate.add(1, 'month')
   let dateOfNextMonth = firstOfNextMonth.set('date', Math.min(days, firstOfNextMonth.daysInMonth())).set('hours', hours) // 翌月の〇日〇時にする

   // 今日が今月の〇日〇時より前の場合は今月、そうでなければ来月
+  return date.startOf('month').isBefore(dateOfThisMonth.startOf('month')) ? dateOfThisMonth : dateOfNextMonth
 }

 let date = dayjs() // 今日をベースとする
 for (let i = 0; i < 12; i++) {
   date = getNextDate(date, 31, 9)
   console.log(date.format('YYYY年M月D日H時m分s秒'))
 }

おわりに

ちなみに、最後の関数は必ず翌月を返すので無駄の多いプログラムになります。
状況によって必要な個所、不要な個所がでてくると思いますが、『次の〇日』の作り方はこれで問題なさそうです。

日付処理は単位が大事だなと思います。addとかdiffとかで日付の計算はライブラリを使えば簡単にできそうだと思いがちですが、単位を合わせたうえで使わないと失敗することが多いです。これまで何度もハマったので今回記事にしてみました。

この記事を元に単位で計算する日付処理ライブラリを作りました。

https://github.com/itte1/unit_date

Discussion