現在時刻から何分前、何時間前、何日前・・を計算するプログラム

# はじめに
SNSで「何時間前の投稿です」みたいなのをよく見ますよね。
よくある機能なので実装例が沢山あるだろうと思っていたのですが、検索してもヒットせず、泣く泣く実装したのでそれを共有します。
Androidアプリ用に作成したので、Kotlinで書きました。

# コード
```kotlin:DateAgoCalculator.kt
import java.util.*
import kotlin.math.max

object DateAgoCalculator {

    enum class TimeUnit {
        SECOND,
        MINUTE,
        HOUR,
        DAY,
        MONTH,
        YEAR
    }

    data class Time(
            val value: Int,
            val unit: TimeUnit
    )

    /** 過去時刻を現在時刻と比較して何分前、何時間前、何か月前になるか計算する */
    fun calc(now: Date, past: Date): Time {
        val diffSec = (now.time - past.time) / 1000L

        // 1分未満であれば秒で返す（マイナスの場合は0秒に）
        if (diffSec < 60)
            return Time(max(0, diffSec.toInt()), TimeUnit.SECOND)

        // 1時間未満であれば分で返す
        if (diffSec < 60 * 60)
            return Time((diffSec / 60).toInt(), TimeUnit.MINUTE)

        // 1日未満であれば時間で返す
        if (diffSec < 60 * 60 * 24)
            return Time((diffSec / (60 * 60)).toInt(), TimeUnit.HOUR)

        // 以降、月によって1ヵ月の日数が違うため、カレンダーで計算する
        val nowCalendar = Calendar.getInstance(TimeZone.getDefault())
        val pastCalendar = Calendar.getInstance(TimeZone.getDefault())
        nowCalendar.time = now
        pastCalendar.time = past
        // 1ヵ月未満であれば日で返す
        if (nowCalendar < getModifiedCalender(pastCalendar, Calendar.MONTH, 1)) {
            // 同じ月の場合
            if (nowCalendar.get(Calendar.MONTH) == pastCalendar.get(Calendar.MONTH))
                return Time(nowCalendar.get(Calendar.DAY_OF_MONTH) - pastCalendar.get(Calendar.DAY_OF_MONTH) + compareTime(nowCalendar, pastCalendar), TimeUnit.DAY)

            // 違う月の場合
            return Time(nowCalendar.get(Calendar.DAY_OF_MONTH) - (pastCalendar.get(Calendar.DAY_OF_MONTH) - pastCalendar.getActualMaximum(Calendar.DAY_OF_MONTH)) +  + compareTime(nowCalendar, pastCalendar), TimeUnit.DAY)
        }

        // 1年未満であれば月で返す
        if (nowCalendar < getModifiedCalender(pastCalendar, Calendar.YEAR, 1)) {
            for (i in 2..12) {
                if (nowCalendar < getModifiedCalender(pastCalendar, Calendar.MONTH, i))
                    return Time(i - 1, TimeUnit.MONTH)
            }
            return Time(12, TimeUnit.MONTH)
        }

        // 1年以上であれば年で返す
        return Time(nowCalendar.get(Calendar.YEAR) - pastCalendar.get(Calendar.YEAR) + compareDate(nowCalendar, pastCalendar), TimeUnit.YEAR)
    }

    /** 過去時刻を現在時刻と比較して何分前、何時間前、何か月前という文字列を取得する */
    fun getLabel(now: Date, past: Date): String {
        val timeAgo = calc(now, past)
        return when (timeAgo.unit) {
            TimeUnit.SECOND -> "0分前"    // 1分未満は0分前表示
            TimeUnit.MINUTE -> "${timeAgo.value}分前"
            TimeUnit.HOUR -> "${timeAgo.value}時間前"
            TimeUnit.DAY -> "${timeAgo.value}日前"
            TimeUnit.MONTH -> "${timeAgo.value}ヵ月前"
            TimeUnit.YEAR -> "${timeAgo.value}年前"
        }
    }

    // 日付操作したカレンダーを取得する（元のカレンダーインスタンスは値を変更したくない）
    private fun getModifiedCalender(base: Calendar, unit: Int, add: Int): Calendar {
        val result = base.clone() as Calendar
        return result.apply { this.add(unit, add) }
    }

    private fun compareDate(now: Calendar, past: Calendar): Int {
        val now_ = now.clone() as Calendar
        now_.set(Calendar.YEAR, past.get(Calendar.YEAR))
        return if (past > now_) -1 else 0
    }

    private fun compareTime(now: Calendar, past: Calendar): Int {
        val now_ = now.clone() as Calendar
        now_.set(Calendar.YEAR, past.get(Calendar.YEAR))
        now_.set(Calendar.MONTH, past.get(Calendar.MONTH))
        now_.set(Calendar.DATE, past.get(Calendar.DATE))
        return if (past > now_) -1 else 0
    }
}
```

# テストコード
これらのテストはすべて通しています（考慮漏れはあるかもしれません・・）

```kotlin:DateAgoCalculatorTest.kt
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import java.util.*

class DateAgoCalculatorTest {

    @Test
    fun `同時刻で0秒前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 26, 11, 30, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(0, DateAgoCalculator.TimeUnit.SECOND)))
    }

    @Test
    fun `マイナス時刻で0秒前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 26, 11, 31, 1)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(0, DateAgoCalculator.TimeUnit.SECOND)))
    }

    @Test
    fun `59秒前で59秒前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 26, 11, 29, 1)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(59, DateAgoCalculator.TimeUnit.SECOND)))
    }

    @Test
    fun `60秒前で1分前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 26, 11, 29, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(1, DateAgoCalculator.TimeUnit.MINUTE)))
    }

    @Test
    fun `119秒前で1分前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 26, 11, 28, 1)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(1, DateAgoCalculator.TimeUnit.MINUTE)))
    }

    @Test
    fun `120秒前で2分前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 26, 11, 28, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(2, DateAgoCalculator.TimeUnit.MINUTE)))
    }

    @Test
    fun `59分59秒前で59分前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 26, 10, 30, 1)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(59, DateAgoCalculator.TimeUnit.MINUTE)))
    }

    @Test
    fun `60分0秒前で1時間前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 26, 10, 30, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(1, DateAgoCalculator.TimeUnit.HOUR)))
    }

    @Test
    fun `23時間59分59秒前で23時間前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 25, 11, 30, 1)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(23, DateAgoCalculator.TimeUnit.HOUR)))
    }

    @Test
    fun `24時間0分0秒前で1日前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 25, 11, 30, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(1, DateAgoCalculator.TimeUnit.DAY)))
    }

    @Test
    fun `47時間59分59秒前で1日前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 24, 11, 30, 1)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(1, DateAgoCalculator.TimeUnit.DAY)))
    }

    @Test
    fun `48時間0分0秒前で2日前を返すこと`() {
        val now = Date(2021, 6, 26, 11, 30, 0)
        val past = Date(2021, 6, 24, 11, 30, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(2, DateAgoCalculator.TimeUnit.DAY)))
    }

    @Test
    fun `現在2021年2月1日0時0分0秒、2021年1月1日0時0分1秒で30日前を返すこと`() {
        val now = Date(2021, 1, 1, 0, 0, 0)
        val past = Date(2021, 0, 1, 0, 0, 1)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(30, DateAgoCalculator.TimeUnit.DAY)))
    }

    @Test
    fun `現在2021年2月1日0時0分0秒、2021年1月31日0時0分0秒で1日前を返すこと`() {
        val now = Date(2021, 1, 1, 0, 0, 0)
        val past = Date(2021, 0, 31, 0, 0, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(1, DateAgoCalculator.TimeUnit.DAY)))
    }

    @Test
    fun `現在2021年1月1日0時0分0秒、2020年12月31日0時0分0秒で1日前を返すこと`() {
        val now = Date(2021, 0, 1, 0, 0, 0)
        val past = Date(2020, 11, 31, 0, 0, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(1, DateAgoCalculator.TimeUnit.DAY)))
    }

    @Test
    fun `現在2021年2月1日0時0分0秒、2021年1月1日0時0分0秒で1ヵ月前を返すこと`() {
        val now = Date(2021, 2, 1, 0, 0, 0)
        val past = Date(2021, 1, 1, 0, 0, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(1, DateAgoCalculator.TimeUnit.MONTH)))
    }

    @Test
    fun `現在2021年3月1日0時0分0秒、2021年1月1日0時0分1秒で1ヵ月前を返すこと`() {
        val now = Date(2021, 2, 1, 0, 0, 0)
        val past = Date(2021, 0, 1, 0, 0, 1)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(1, DateAgoCalculator.TimeUnit.MONTH)))
    }

    @Test
    fun `現在2021年3月1日0時0分0秒、2021年1月1日0時0分0秒で2ヵ月前を返すこと`() {
        val now = Date(2021, 2, 1, 0, 0, 0)
        val past = Date(2021, 0, 1, 0, 0, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(2, DateAgoCalculator.TimeUnit.MONTH)))
    }

    @Test
    fun `現在2021年1月1日0時0分0秒、2020年1月1日0時0分0秒で1年前を返すこと`() {
        val now = Date(2021, 0, 1, 0, 0, 0)
        val past = Date(2020, 0, 1, 0, 0, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(1, DateAgoCalculator.TimeUnit.YEAR)))
    }

    @Test
    fun `現在2021年1月1日0時0分0秒、2019年1月1日0時0分1秒で1年前を返すこと`() {
        val now = Date(2021, 0, 1, 0, 0, 0)
        val past = Date(2019, 0, 1, 0, 0, 1)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(1, DateAgoCalculator.TimeUnit.YEAR)))
    }

    @Test
    fun `現在2021年1月1日0時0分0秒、2019年1月1日0時0分0秒で2年前を返すこと`() {
        val now = Date(2021, 0, 1, 0, 0, 0)
        val past = Date(2019, 0, 1, 0, 0, 0)
        val r = DateAgoCalculator.calc(now, past)
        assertThat(r, `is`(DateAgoCalculator.Time(2, DateAgoCalculator.TimeUnit.YEAR)))
    }
}
```

# おわりに
月や年の計算まわりはややこしいですね！
もしバグありましたら教えていただけると幸いです。

最後に宣伝になりますが、このコードを組み込んだAndroidアプリ「ココメモ」をリリース中です！
現在地をメモ帳感覚でラクラク記録できます。お出かけの記録などにいかがでしょうか？
<a href='https://play.google.com/store/apps/details?id=com.allfort.locationmemo&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img width=200 alt='Google Play で手に入れよう' src='https://play.google.com/intl/en_us/badges/static/images/badges/ja_badge_web_generic.png'/></a>