File Coverage

File:blib/lib/Date/Age.pm
Coverage:89.5%

linestmtbrancondsubtimecode
1package 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
12our @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
24our $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
73sub 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
150sub 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
176sub _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
181sub _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
203sub _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
227sub _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
242sub _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
249sub _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
2581;
259