🗓️

生年月日からの経過日数を○年○ヶ月○日と計算するロジック

2022/05/06に公開

まとめ

  • 生年月日からの経過日数を○年○か月○日と計算する仕様が複雑だった。
  • 年齢の法律や年齢計算サービスを参考にして仕様を考えた。
  • 仕様を満たすdart packageが見当たらなかったので自前で実装した。

やりたいこと

生年月日からの経過日数を○年○か月○日と表示したい。


左上に生年月日(2019-11-01)からの経過日数を表示

実現手順

    1. 要件を明確にする
    • 生年月日が2020-01-31の1か月後はいつか?
    • うるう日生まれの1歳の誕生日はいつか?
    1. 実装する

用語

要件を説明するために以下の用語を使うことにする。

  • 経過日数を求める日を測定日と呼ぶ。
    • 例: 生年月日が2019-11-01、測定日が2019-12-01だとすると、経過日数は0年1か月0日となる。
  • その日が属する月を測定月と呼ぶ。
    • 例: 測定日が2019-12-01の測定月は2019-12である。

1.要件を明確にする

カウント方法

生年月日を0年0か月0日とし、その翌日からカウントする。生まれて1年間を「0歳」とする「満年齢」の考え方を採用する。

経過日数の決め方

法律を参考にする

年齢計算ニ関スル法律には以下のように書いてある。

② 民法第百四十三条ノ規定ハ年齢ノ計算ニ之ヲ準用ス

そこで民法第百四十三条を見ると以下のように書いてある

2 週、月又は年の初めから期間を起算しないときは、その期間は、最後の週、月又は年においてその起算日に応当する日の前日に満了する。ただし、月又は年によって期間を定めた場合において、最後の月に応当する日がないときは、その月の末日に満了する

以下で具体的に考えてみる。

a.生年月日と同じ日にちが測定月に存在する場合

生年月日が2022-03-05の場合、測定月2022-04に05日は存在する。
その期間は、最後の週、月又は年においてその起算日に応当する日の前日に満了する」に従って、
2022-03-05から1か月間の満了日は2022-04-04である。

この時の経過日数をどう考えるか?
生年月日から1か月の期間満了日の次の日を0年1か月0日としたい。生年月日から1か月の期間を経過した日だし、例えば2022-03-05生まれの1か月後は2022-04-05という、1か月後の感覚とも対応する。
一般化すると以下のような感じか。

  • 生年月日からy年間の期間満了日の次の日をy年0か月0日
  • y年0か月0日の測定日からmか月間の期間満了日の次の日をy年mか月0日

b.生年月日と同じ日にちが測定月に存在しない場合

例1: 生年月日が2022-01-31、測定月2022-02の場合

測定月2022-02に31日は存在しない。
最後の月に応当する日がないときは、その月の末日に満了する」に従って、
2022-01-31から1か月間の満了日は2022-02-28である。
経過日数の定義に照らすと、次の日の2022-03-01の経過日数は0年1か月0日になる。

例2: うるう日生まれの経過日数1年0か月0日になる日は?

生年月日が2020-02-29でそこから1年間を考える。
最後の月に応当する日がないときは、その月の末日に満了するに則り、1年間の満了日は2021-02-28となる。従って、次の日の2021-03-01が経過日数1年0か月0日となる。

うるう日については、うるう年をめぐる法令も参考になる。

したがって、例えば西暦2020年2月29日生まれの者は、西暦2021年2月28日限り(すなわち2月28日の24時)をもって満1歳になります。ただ、実際に誕生日を祝うのは、満1歳になった次の日、すなわちうるう年では2月29日、平年では(2月29日がないので)3月1日ということになるのでしょう。

年齢計算サービスを参考にする

年齢計算サイトを見ると、2020年1月31日生まれは2020年2月29日で経過日数1か月としている。
うるう日生まれも、翌年2月28日で経過日数1年としている。


2020年1月31日生まれは2020年2月29日で経過日数1か月としている

うるう日生まれは、翌年2月28日で経過日数1か月としている

別の年齢計算サイトも同様だった

1か月の期間満了日の次の日ではなく当日の経過日数を0年1か月0日としているため、「法律を参考にする」で書いた定義とは異なる。

いろんな日時を試した感じ、年齢計算サービスでは以下のような動作をしていた。

  • 「生年月日と同じ日にちが測定月に存在しない」 かつ 「測定日が月末」のとき
    • 1か月の期間満了日(=月末)を0年1か月0日とする
      • 生年月日2020年1月31日 測定日2020年2月29日 経過日数0年1か月0日
      • 生年月日2020年1月30日 測定日2020年2月29日 経過日数0年1か月0日
      • 生年月日2020年2月29日 測定日2021年2月28日 経過日数1年0か月0日
  • 上記以外の場合
    • 1か月の期間満了日の次の日を0年1か月0日とする
    • 「法律を参考にする」で考えた経過日数の定義と同じ
      • 生年月日2020年1月28日 測定日2020年2月28日 経過日数0年1か月0日
      • 生年月日2020年4月1日 測定日2021年4月1日 経過日数1年0か月0日

結論

両方実装してアプリで触ってみた結果、既存のサービスを参考にした要件を採用することにした。こちらの方が自分の直感に合っていたため。

2. 実装する

dart:coreパッケージには経過日数を計算するロジックなし

Durationには経過日数を表示するロジックは無かった。
Duration#inDaysは日数を表示するだけだった。

ライブラリ見当たらず

ライブラリを漁ったが、残念ながら上記の要件をみたすdart packageは無かった。
以下を試したが、2020年1月31日生まれ2020年2月29日で計算するとバグった。

age_calculator(v1.0.0)のバグったコード
    group('1月31日', () {
      final birth = DateTime(2022, 1, 31);

      test('2月と3月の間', () {
        print(AgeCalculator.age(birth, today: DateTime(2022, 3, 1)).toString()); // Years: 0, Months: 1, Days: -2
        print(AgeCalculator.age(birth, today: DateTime(2022, 3, 2)).toString()); // Years: 0, Months: 1, Days: -1
        print(AgeCalculator.age(birth, today: DateTime(2022, 3, 3)).toString()); // Years: 0, Months: 1, Days: 0
      });
    });

自前で実装

実装
age.dart
class Age {
  Age({required this.years, required this.months, required this.days})
      : assert(years >= 0),
        assert(months >= 0 && months <= 11),
        assert(days >= 0 && days <= 30);

  factory Age.from(DateTime birth, {required DateTime today}) {
    if (today.isBeforeDay(birth)) throw ArgumentError('today must not before birth. $today, $birth');
    var fullMonths = (today.year - birth.year) * 12 + (today.month - birth.month);
    final int days;

    if (today.day >= birth.day) {
      // today月のbirth.day日を0としてカウント
      days = today.day - birth.day;
    } else {
      if (birth.day > DateTime(today.year, today.month).daysInMonth && today.isLastDayOfMonth) {
        // 生年月日の日付が今月の末日より大きくて、今日が末日の場合
        // 日数を0とする
        // 例: 生年月日2020-01-31 今月末2020-02-29 0年1ヶ月0日
        days = 0;
      } else {
        fullMonths--; //月齢をデクリメント

        final totalDaysInLastMonth = DateTime(today.year, today.month - 1).daysInMonth;
        // 誕生日の日付 ~ 末日までの日数
        // 誕生日の日付が先月の末日より大きい場合は、末日の1日をカウントして1
        final daysInLastMonth = birth.day > totalDaysInLastMonth ? 1 : totalDaysInLastMonth - birth.day + 1;

        // 先月の誕生日の日付 ~ 今日までの日数に、0から数えたいので-1する
        days = daysInLastMonth + today.day - 1;
      }
    }

    final years = (fullMonths / 12).floor();
    final months = fullMonths % 12;

    return Age(years: years, months: months, days: days);
  }

  final int years;
  final int months;
  final int days;

  
  String toString() {
    if (isBirthDay) return '$years歳の誕生日🎉';
    return '$years$monthsか月$days日';
  }

  bool get isBirthDay => months == 0 && days == 0;
}

extension DayTimeExtension on DateTime {
  static const _daysInMonthArray = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

  int get daysInMonth {
    var result = _daysInMonthArray[month];
    if (month == 2 && _isLeapYear(year)) result++;
    return result;
  }

  bool isBeforeDay(DateTime other) => _asDate.isBefore(other._asDate);
  
  bool get isLastDayOfMonth => daysInMonth == day;
  
  bool _isLeapYear(int year) => (year % 4 == 0) && ((year % 100 != 0) || (year % 400 == 0));

  DateTime get _asDate => DateTime(year, month, day); // 日付専用の型は無いのでDateTimeを使う。時間は0:00:00とする。
}

テストも以下のような感じで書いた

テスト
age_test.dart
import 'package:fugu_diary/model/service/pet/age.dart';
import 'package:test/test.dart';

void main() {
  group('pet age', () {
    group('誕生日が閏日', () {
      final birth = DateTime(2020, 2, 29);

      test('1か月前後', () {
        expect(
          Age.from(
            birth,
            today: DateTime(2020, 3, 28),
          ).toString(),
          '0歳0か月28日',
        );

        expect(
          Age.from(
            birth,
            today: DateTime(2020, 3, 29),
          ).toString(),
          '0歳1か月0日',
        );

        expect(
          Age.from(
            birth,
            today: DateTime(2020, 3, 30),
          ).toString(),
          '0歳1か月1日',
        );
      });

      test('1歳前後', () {
        expect(
          Age.from(
            birth,
            today: DateTime(2021, 2, 28),
          ).toString(),
          '1歳の誕生日🎉',
        );

        expect(
          Age.from(
            birth,
            today: DateTime(2021, 3, 1),
          ).toString(),
          '1歳0か月1日',
        );

        expect(
          Age.from(
            birth,
            today: DateTime(2021, 3, 2),
          ).toString(),
          '1歳0か月2日',
        );
      });
    });

    test('誕生日の日付と今日の日付が同じ', () {
      final birth = DateTime(2022, 1, 29);
      final today = DateTime(2022, 4, 29);
      // 素直に年月日をそれぞれ引き算した結果になる
      expect(
        Age.from(birth, today: today).toString(),
        '0歳3か月0日',
      );
    });

    test('誕生日の日付より今日の日付の方が大きい', () {
      final birth = DateTime(2022, 1, 29);
      final today = DateTime(2022, 4, 30);
      // 素直に年月日をそれぞれ引き算した結果になる
      expect(
        Age.from(birth, today: today).toString(),
        '0歳3か月1日',
      );
    });

    group('誕生日の日付より今日の日付の方が小さい', () {
      test('誕生日の日付が先月の末日より大きい', () {
        // (誕生日の)29日は(4月の)30日より大きい
        final birth = DateTime(2022, 1, 29);
        final today = DateTime(2022, 5, 1);
        expect(
          Age.from(birth, today: today).toString(),
          '0歳3か月2日',
        );
      });

      test('誕生日の日付が先月の末日より小さい', () {
        // (誕生日の)29日は(4月の)30日より小さい
        final birth = DateTime(2022, 1, 31);
        final today = DateTime(2022, 5, 1);
        expect(
          Age.from(birth, today: today).toString(),
          '0歳3か月1日',
        );
      });
    });

    group('境界値っぽいデータでテスト', () {
      final birth = DateTime(2022, 2, 8);

      test('誕生前', () {
        expect(
          () => Age.from(birth, today: DateTime(2022, 2, 6)).toString(),
          throwsA(const TypeMatcher<ArgumentError>()),
        );
      });

      test('誕生', () {
        expect(
          Age.from(birth, today: birth).toString(),
          '0歳の誕生日🎉', // marchDays - 1日
        );
      });

      test('2か月前後', () {
        expect(
          Age.from(birth, today: DateTime(2022, 4, 7)).toString(),
          '0歳1か月30日', // marchDays - 1日
        );

        expect(
          Age.from(birth, today: DateTime(2022, 4, 8)).toString(),
          '0歳2か月0日',
        );

        expect(
          Age.from(birth, today: DateTime(2022, 4, 9)).toString(),
          '0歳2か月1日',
        );
      });

      test('3か月前後', () {
        expect(
          Age.from(birth, today: DateTime(2022, 5, 7)).toString(),
          '0歳2か月29日', // 0 ~ (aprilDays -1)日
        );

        expect(
          Age.from(birth, today: DateTime(2022, 5, 8)).toString(),
          '0歳3か月0日',
        );

        expect(
          Age.from(birth, today: DateTime(2022, 5, 9)).toString(),
          '0歳3か月1日',
        );
      });

      test('1歳前後', () {
        expect(
          Age.from(birth, today: DateTime(2023, 2, 7)).toString(),
          '0歳11か月30日',
        );

        expect(
          Age.from(birth, today: DateTime(2023, 2, 8)).toString(),
          '1歳の誕生日🎉',
        );

        expect(
          Age.from(birth, today: DateTime(2023, 2, 9)).toString(),
          '1歳0か月1日',
        );
      });

      test('3歳前後', () {
        expect(
          Age.from(birth, today: DateTime(2025, 2, 7)).toString(),
          '2歳11か月30日',
        );

        expect(
          Age.from(birth, today: DateTime(2025, 2, 8)).toString(),
          '3歳の誕生日🎉',
        );

        expect(
          Age.from(birth, today: DateTime(2025, 2, 9)).toString(),
          '3歳0か月1日',
        );
      });
    });
  });
}

参考

http://www5d.biglobe.ne.jp/Jusl/TomoLaw/KikanKeisan.html

Discussion