生年月日からの経過日数を○年○ヶ月○日と計算するロジック
まとめ
- 生年月日からの経過日数を○年○か月○日と計算する仕様が複雑だった。
- 年齢の法律や年齢計算サービスを参考にして仕様を考えた。
- 仕様を満たすdart packageが見当たらなかったので自前で実装した。
やりたいこと
生年月日からの経過日数を○年○か月○日と表示したい。
左上に生年月日(2019-11-01)からの経過日数を表示
実現手順
-
- 要件を明確にする
- 生年月日が2020-01-31の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
});
});
自前で実装
実装
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とする。
}
テストも以下のような感じで書いた
テスト
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日',
);
});
});
});
}
参考
Discussion