| File: | blib/lib/Date/Age.pm |
| Coverage: | 89.5% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Date::Age; | |||||
| 2 | ||||||
| 3 | 3 3 | 284876 3 | use 5.010; | |||
| 4 | ||||||
| 5 | 3 3 3 | 4 3 26 | use strict; | |||
| 6 | 3 3 3 | 4 2 63 | use warnings; | |||
| 7 | ||||||
| 8 | 3 3 3 | 3 5 72 | use Carp qw(carp croak); | |||
| 9 | 3 3 3 | 5 1 37 | use Exporter 'import'; | |||
| 10 | 3 3 3 | 630 2453 1337 | use Time::Local qw(timelocal); | |||
| 11 | ||||||
| 12 | our @EXPORT_OK = qw(describe details); | |||||
| 13 | ||||||
| 14 - 22 | =head1 NAME Date::Age - Return an age or age range from date(s) =head1 VERSION Version 0.07 =cut | |||||
| 23 | ||||||
| 24 | our $VERSION = '0.07'; | |||||
| 25 | ||||||
| 26 - 71 | =head1 SYNOPSIS
use Date::Age qw(describe details);
print describe('1943', '2016-01-01'), "\n"; # '72-73'
my $data = details('1943-05-01', '2016-01-01');
# { min_age => 72, max_age => 72, range => '72', precise => 72 }
=head1 DESCRIPTION
This module calculates the age or possible age range between a date of birth
and another date (typically now or a death date).
It works even with partial dates.
=head1 METHODS
=head1 FUNCTIONS
=head2 describe
my $range = describe($dob);
my $range = describe($dob, $ref_date);
Returns a human-readable age or age range for the supplied date of birth.
C<describe()> accepts a date of birth in any of the formats supported by
L</details> (year only, year-month, or full year-month-day). An optional
reference date may also be provided; if omitted, the current local date is
used.
Because partial dates imply uncertainty, the routine may return either a
single age (e.g. C<"72">) or an age range (e.g. C<"72-73">). Year-only and
year-month dates can span a range of possible birthdays, and therefore a
range of possible ages.
Examples:
describe('1943'); # e.g. '80-81'
describe('1943-05', '2016'); # '72-73'
describe('1943-05-01', '2016-01-01'); # '72'
This routine is a convenience wrapper around C<details()> that returns only
the formatted range string.
=cut | |||||
| 72 | ||||||
| 73 | sub describe { | |||||
| 74 | 4 | 67145 | if($_[0] eq __PACKAGE__) { | |||
| 75 | 0 | 0 | shift; | |||
| 76 | } | |||||
| 77 | ||||||
| 78 | 4 | 7 | croak('Usage: ', __PACKAGE__, '::describe($dob, $ref)') if(scalar(@_) == 0); | |||
| 79 | ||||||
| 80 | 4 | 5 | my ($dob, $ref) = @_; | |||
| 81 | 4 | 6 | my $info = details($dob, $ref); | |||
| 82 | 3 | 8 | return $info->{range}; | |||
| 83 | } | |||||
| 84 | ||||||
| 85 - 148 | =head2 details
my $info = details($dob);
my $info = details($dob, $ref_date);
Returns a hashref describing the full computed age information. This routine
performs the underlying date-range expansion and age calculation that
C<describe()> relies on.
The returned hashref contains:
=over 4
=item * C<min_age>
The minimum possible age based on the earliest possible birthday within the
supplied date specification.
=item * C<max_age>
The maximum possible age based on the latest possible birthday.
=item * C<range>
A string representation of the age or age range, such as C<"72"> or
C<"72-73">.
=item * C<precise>
If the age is unambiguous (e.g. the date of birth and reference date are both
fully specified), this is the exact age as an integer. Otherwise it is
C<undef>.
=back
Supported date formats for both C<$dob> and C<$ref_date> are:
=over 4
=item * C<YYYY> - year only (e.g. C<1943>)
=item * C<YYYY-MM> - year and month (e.g. C<1943-05>)
=item * C<YYYY-MM-DD> - full date (e.g. C<1943-05-01>)
=back
Invalid or unrecognised date strings will cause the routine to C<croak()>.
Example:
my $info = details('1943-05-01', '2016-01-01');
# {
# min_age => 72,
# max_age => 72,
# range => '72',
# precise => 72,
# }
When the reference date is omitted, the current local date (YYYY-MM-DD) is
used.
=cut | |||||
| 149 | ||||||
| 150 | sub details { | |||||
| 151 | 13 | 3153 | if($_[0] eq __PACKAGE__) { | |||
| 152 | 0 | 0 | shift; | |||
| 153 | } | |||||
| 154 | ||||||
| 155 | 13 | 14 | croak('Usage: ', __PACKAGE__, '::details($dob, $ref)') if(scalar(@_) == 0); | |||
| 156 | ||||||
| 157 | 13 | 12 | my ($dob, $ref) = @_; | |||
| 158 | ||||||
| 159 | 13 | 14 | my ($dob_early, $dob_late) = _parse_date_range($dob); | |||
| 160 | 8 | 12 | my ($ref_early, $ref_late) = _parse_date_range($ref // _now_string()); | |||
| 161 | ||||||
| 162 | 8 | 7 | my $min_age = _calc_age_localtime($dob_late, $ref_early); | |||
| 163 | 8 | 8 | my $max_age = _calc_age_localtime($dob_early, $ref_late); | |||
| 164 | ||||||
| 165 | 8 | 11 | my $range_str = $min_age == $max_age ? $min_age : "$min_age-$max_age"; | |||
| 166 | 8 | 5 | my $precise = ($min_age == $max_age) ? $min_age : undef; | |||
| 167 | ||||||
| 168 | return { | |||||
| 169 | 8 | 28 | min_age => $min_age, | |||
| 170 | max_age => $max_age, | |||||
| 171 | range => $range_str, | |||||
| 172 | precise => $precise, | |||||
| 173 | }; | |||||
| 174 | } | |||||
| 175 | ||||||
| 176 | sub _now_string { | |||||
| 177 | 1 | 999 | my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(); | |||
| 178 | 1 | 3 | return sprintf('%04d-%02d-%02d', $year + 1900, $mon + 1, $mday); | |||
| 179 | } | |||||
| 180 | ||||||
| 181 | sub _calc_age_localtime { | |||||
| 182 | 16 | 13 | my ($dob, $ref) = @_; # both in YYYY-MM-DD format | |||
| 183 | ||||||
| 184 | # Parse manually | |||||
| 185 | 16 | 21 | my ($dy, $dm, $dd) = split /-/, $dob; | |||
| 186 | 16 | 14 | my ($ry, $rm, $rd) = split /-/, $ref; | |||
| 187 | ||||||
| 188 | # Convert to epoch for comparison | |||||
| 189 | # Note: months are 0-11 for timelocal | |||||
| 190 | 16 | 35 | my $dob_epoch = timelocal(0, 0, 0, $dd, $dm - 1, $dy); | |||
| 191 | 16 | 431 | my $ref_epoch = timelocal(0, 0, 0, $rd, $rm - 1, $ry); | |||
| 192 | ||||||
| 193 | 16 | 325 | my $age = $ry - $dy; | |||
| 194 | ||||||
| 195 | # Check if birthday has occurred this year | |||||
| 196 | 16 | 16 | if ($ref_epoch < timelocal(0, 0, 0, $dd, $dm - 1, $ry)) { | |||
| 197 | 8 | 205 | $age--; | |||
| 198 | } | |||||
| 199 | ||||||
| 200 | 16 | 147 | return $age; | |||
| 201 | } | |||||
| 202 | ||||||
| 203 | sub _parse_date_range { | |||||
| 204 | 21 | 11 | my $date = shift; | |||
| 205 | ||||||
| 206 | 21 | 51 | if ($date =~ /^\d{4}-\d{2}-\d{2}$/) { | |||
| 207 | 16 | 16 | _validate_ymd_strict($date); | |||
| 208 | 12 | 15 | return ($date, $date); | |||
| 209 | } elsif ($date =~ /^(\d{4})-(\d{2})$/) { | |||||
| 210 | 2 | 3 | my ($y, $m) = ($1, $2); | |||
| 211 | 2 | 5 | die "Invalid month in date '$date'" if $m < 1 || $m > 12; | |||
| 212 | ||||||
| 213 | 2 | 2 | my $start = "$y-$m-01"; | |||
| 214 | 2 | 2 | my $end = _end_of_month($y, $m); | |||
| 215 | ||||||
| 216 | 2 | 2 | _validate_ymd_strict($start); | |||
| 217 | 2 | 3 | _validate_ymd_strict($end); | |||
| 218 | ||||||
| 219 | 2 | 2 | return ($start, $end); | |||
| 220 | } elsif ($date =~ /^(\d{4})$/) { | |||||
| 221 | 2 | 5 | return ("$1-01-01", "$1-12-31"); | |||
| 222 | } else { | |||||
| 223 | 1 | 20 | die "Unrecognized date format: $date"; | |||
| 224 | } | |||||
| 225 | } | |||||
| 226 | ||||||
| 227 | sub _validate_ymd_strict { | |||||
| 228 | 20 | 8 | my $date = $_[0]; | |||
| 229 | ||||||
| 230 | # YYYY-MM-DD only | |||||
| 231 | 20 | 27 | return unless $date =~ /^(\d{4})-(\d{2})-(\d{2})$/; | |||
| 232 | 20 | 31 | my ($y, $m, $d) = ($1, $2, $3); | |||
| 233 | ||||||
| 234 | 20 | 40 | die "Invalid month in date '$date'" if $m < 1 || $m > 12; | |||
| 235 | ||||||
| 236 | 18 | 14 | my @dim = (31, 28 + _is_leap($y), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31); | |||
| 237 | 18 | 15 | my $max_d = $dim[$m - 1]; | |||
| 238 | ||||||
| 239 | 18 | 39 | die "Invalid day in date '$date'" if $d < 1 || $d > $max_d; | |||
| 240 | } | |||||
| 241 | ||||||
| 242 | sub _end_of_month { | |||||
| 243 | 5 | 961 | my ($y, $m) = @_; | |||
| 244 | ||||||
| 245 | 5 | 4 | my @days_in_month = (31, 28 + _is_leap($y), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31); | |||
| 246 | 5 | 11 | return sprintf('%04d-%02d-%02d', $y, $m, $days_in_month[$m - 1]); | |||
| 247 | } | |||||
| 248 | ||||||
| 249 | sub _is_leap { | |||||
| 250 | 27 | 251 | my $y = $_[0]; | |||
| 251 | ||||||
| 252 | 27 | 36 | return 1 if $y % 400 == 0; | |||
| 253 | 13 | 15 | return 0 if $y % 100 == 0; | |||
| 254 | 12 | 14 | return 1 if $y % 4 == 0; | |||
| 255 | 4 | 8 | return 0; | |||
| 256 | } | |||||
| 257 | ||||||
| 258 | 1; | |||||
| 259 | ||||||