File Coverage

blib/lib/Date/Age.pm
Criterion Covered Total %
statement 71 73 97.2
branch 28 34 82.3
condition 6 12 50.0
subroutine 14 14 100.0
pod 2 2 100.0
total 121 135 89.6


line stmt bran cond sub pod time code
1             package Date::Age;
2              
3 3     3   833054 use 5.010;
  3         12  
4              
5 3     3   17 use strict;
  3         6  
  3         162  
6 3     3   22 use warnings;
  3         20  
  3         225  
7              
8 3     3   34 use Carp qw(carp croak);
  3         8  
  3         261  
9 3     3   17 use Exporter 'import';
  3         5  
  3         118  
10 3     3   1824 use Time::Local qw(timelocal);
  3         7341  
  3         4160  
11              
12             our @EXPORT_OK = qw(describe details);
13              
14             =head1 NAME
15              
16             Date::Age - Return an age or age range from date(s)
17              
18             =head1 VERSION
19              
20             Version 0.07
21              
22             =cut
23              
24             our $VERSION = '0.07';
25              
26             =head1 SYNOPSIS
27              
28             use Date::Age qw(describe details);
29              
30             print describe('1943', '2016-01-01'), "\n"; # '72-73'
31              
32             my $data = details('1943-05-01', '2016-01-01');
33             # { min_age => 72, max_age => 72, range => '72', precise => 72 }
34              
35             =head1 DESCRIPTION
36              
37             This module calculates the age or possible age range between a date of birth
38             and another date (typically now or a death date).
39             It works even with partial dates.
40              
41             =head1 METHODS
42              
43             =head1 FUNCTIONS
44              
45             =head2 describe
46              
47             my $range = describe($dob);
48             my $range = describe($dob, $ref_date);
49              
50             Returns a human-readable age or age range for the supplied date of birth.
51              
52             C accepts a date of birth in any of the formats supported by
53             L (year only, year-month, or full year-month-day). An optional
54             reference date may also be provided; if omitted, the current local date is
55             used.
56              
57             Because partial dates imply uncertainty, the routine may return either a
58             single age (e.g. C<"72">) or an age range (e.g. C<"72-73">). Year-only and
59             year-month dates can span a range of possible birthdays, and therefore a
60             range of possible ages.
61              
62             Examples:
63              
64             describe('1943'); # e.g. '80-81'
65             describe('1943-05', '2016'); # '72-73'
66             describe('1943-05-01', '2016-01-01'); # '72'
67              
68             This routine is a convenience wrapper around C that returns only
69             the formatted range string.
70              
71             =cut
72              
73             sub describe {
74 4 50   4 1 237081 if($_[0] eq __PACKAGE__) {
75 0         0 shift;
76             }
77              
78 4 50       13 croak('Usage: ', __PACKAGE__, '::describe($dob, $ref)') if(scalar(@_) == 0);
79              
80 4         12 my ($dob, $ref) = @_;
81 4         16 my $info = details($dob, $ref);
82 3         16 return $info->{range};
83             }
84              
85             =head2 details
86              
87             my $info = details($dob);
88             my $info = details($dob, $ref_date);
89              
90             Returns a hashref describing the full computed age information. This routine
91             performs the underlying date-range expansion and age calculation that
92             C relies on.
93              
94             The returned hashref contains:
95              
96             =over 4
97              
98             =item * C
99              
100             The minimum possible age based on the earliest possible birthday within the
101             supplied date specification.
102              
103             =item * C
104              
105             The maximum possible age based on the latest possible birthday.
106              
107             =item * C
108              
109             A string representation of the age or age range, such as C<"72"> or
110             C<"72-73">.
111              
112             =item * C
113              
114             If the age is unambiguous (e.g. the date of birth and reference date are both
115             fully specified), this is the exact age as an integer. Otherwise it is
116             C.
117              
118             =back
119              
120             Supported date formats for both C<$dob> and C<$ref_date> are:
121              
122             =over 4
123              
124             =item * C - year only (e.g. C<1943>)
125              
126             =item * C - year and month (e.g. C<1943-05>)
127              
128             =item * C - full date (e.g. C<1943-05-01>)
129              
130             =back
131              
132             Invalid or unrecognised date strings will cause the routine to C.
133              
134             Example:
135              
136             my $info = details('1943-05-01', '2016-01-01');
137              
138             # {
139             # min_age => 72,
140             # max_age => 72,
141             # range => '72',
142             # precise => 72,
143             # }
144              
145             When the reference date is omitted, the current local date (YYYY-MM-DD) is
146             used.
147              
148             =cut
149              
150             sub details {
151 13 50   13 1 11650 if($_[0] eq __PACKAGE__) {
152 0         0 shift;
153             }
154              
155 13 50       36 croak('Usage: ', __PACKAGE__, '::details($dob, $ref)') if(scalar(@_) == 0);
156              
157 13         30 my ($dob, $ref) = @_;
158              
159 13         36 my ($dob_early, $dob_late) = _parse_date_range($dob);
160 8   33     26 my ($ref_early, $ref_late) = _parse_date_range($ref // _now_string());
161              
162 8         19 my $min_age = _calc_age_localtime($dob_late, $ref_early);
163 8         17 my $max_age = _calc_age_localtime($dob_early, $ref_late);
164              
165 8 100       25 my $range_str = $min_age == $max_age ? $min_age : "$min_age-$max_age";
166 8 100       17 my $precise = ($min_age == $max_age) ? $min_age : undef;
167              
168             return {
169 8         90 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     1   3589 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime();
178 1         12 return sprintf('%04d-%02d-%02d', $year + 1900, $mon + 1, $mday);
179             }
180              
181             sub _calc_age_localtime {
182 16     16   34 my ($dob, $ref) = @_; # both in YYYY-MM-DD format
183              
184             # Parse manually
185 16         88 my ($dy, $dm, $dd) = split /-/, $dob;
186 16         45 my ($ry, $rm, $rd) = split /-/, $ref;
187              
188             # Convert to epoch for comparison
189             # Note: months are 0-11 for timelocal
190 16         55 my $dob_epoch = timelocal(0, 0, 0, $dd, $dm - 1, $dy);
191 16         1160 my $ref_epoch = timelocal(0, 0, 0, $rd, $rm - 1, $ry);
192              
193 16         866 my $age = $ry - $dy;
194              
195             # Check if birthday has occurred this year
196 16 100       42 if ($ref_epoch < timelocal(0, 0, 0, $dd, $dm - 1, $ry)) {
197 8         521 $age--;
198             }
199              
200 16         474 return $age;
201             }
202              
203             sub _parse_date_range {
204 21     21   37 my $date = shift;
205              
206 21 100       138 if ($date =~ /^\d{4}-\d{2}-\d{2}$/) {
    100          
    100          
207 16         43 _validate_ymd_strict($date);
208 12         36 return ($date, $date);
209             } elsif ($date =~ /^(\d{4})-(\d{2})$/) {
210 2         10 my ($y, $m) = ($1, $2);
211 2 50 33     15 die "Invalid month in date '$date'" if $m < 1 || $m > 12;
212              
213 2         6 my $start = "$y-$m-01";
214 2         8 my $end = _end_of_month($y, $m);
215              
216 2         7 _validate_ymd_strict($start);
217 2         6 _validate_ymd_strict($end);
218              
219 2         7 return ($start, $end);
220             } elsif ($date =~ /^(\d{4})$/) {
221 2         8 return ("$1-01-01", "$1-12-31");
222             } else {
223 1         11 die "Unrecognized date format: $date";
224             }
225             }
226              
227             sub _validate_ymd_strict {
228 20     20   33 my $date = $_[0];
229              
230             # YYYY-MM-DD only
231 20 50       87 return unless $date =~ /^(\d{4})-(\d{2})-(\d{2})$/;
232 20         77 my ($y, $m, $d) = ($1, $2, $3);
233              
234 20 100 66     134 die "Invalid month in date '$date'" if $m < 1 || $m > 12;
235              
236 18         39 my @dim = (31, 28 + _is_leap($y), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31);
237 18         40 my $max_d = $dim[$m - 1];
238              
239 18 100 66     116 die "Invalid day in date '$date'" if $d < 1 || $d > $max_d;
240             }
241              
242             sub _end_of_month {
243 5     5   3809 my ($y, $m) = @_;
244              
245 5         13 my @days_in_month = (31, 28 + _is_leap($y), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31);
246 5         38 return sprintf('%04d-%02d-%02d', $y, $m, $days_in_month[$m - 1]);
247             }
248              
249             sub _is_leap {
250 27     27   1017 my $y = $_[0];
251              
252 27 100       108 return 1 if $y % 400 == 0;
253 13 100       41 return 0 if $y % 100 == 0;
254 12 100       38 return 1 if $y % 4 == 0;
255 4         15 return 0;
256             }
257              
258             1;
259              
260             __END__