File Coverage

blib/lib/DateTimeX/Period.pm
Criterion Covered Total %
statement 97 103 94.1
branch 38 42 90.4
condition n/a
subroutine 26 26 100.0
pod 4 4 100.0
total 165 175 94.2


line stmt bran cond sub pod time code
1             package DateTimeX::Period;
2 6     6   134252 use parent DateTime;
  6         2099  
  6         34  
3              
4 6     6   1329011 use 5.006;
  6         24  
  6         223  
5 6     6   51 use strict;
  6         11  
  6         219  
6 6     6   45 use warnings FATAL => 'all';
  6         12  
  6         296  
7              
8 6     6   38 use Carp;
  6         12  
  6         553  
9 6     6   38 use Try::Tiny;
  6         11  
  6         9777  
10              
11             =head1 NAME
12              
13             DateTimeX::Period - Provides safe methods to get start and end of period
14             in all timezones.
15              
16             =head1 VERSION
17              
18             This document describes DateTimeX::Period version 0.03
19              
20             =cut
21              
22             our $VERSION = '0.03';
23              
24             =head1 SYNOPSIS
25              
26             # Optionally get local timezone
27             use DateTime::TimeZone qw();
28             my $timezone = DateTime::TimeZone->new( name => 'local' )->name();
29              
30             use DateTimeX::Period qw();
31              
32             my $dt = DateTimeX::Period->now(
33             time_zone => $timezone,
34             );
35             my $interval_start = $dt->get_start('month');
36             my $interval_end = $dt->get_end('month');
37              
38             =head1 DESCRIPTION
39              
40             DateTimeX::Period provides easy yet safe methods to work in period context
41             such as a day for all timezones. It is a subclass of DateTime, thus benefits
42             from its great caching.
43              
44             It is recommended practise to work in UTC and switch to specific timezones only
45             when needed. IF YOU CAN WORK IN UTC TIME, THEN THIS MODULE IS NOT FOR YOU!!!
46              
47             Yet sometimes this is not possible and this module may help you. It works
48             around problems such as Daylight Saving Time ( DST ) that causes DateTime to
49             throw runtime errors.
50              
51             =head1 ISSUES THIS MODULE IS TRYING TO SOLVE
52              
53             1. Assume you want to get start of the month. It's convenient to use
54             truncate() available in DateTime, however this would throw an error:
55              
56             use DateTime;
57             my $dt = DateTime->new(
58             year => 2011,
59             month => 4,
60             day => 2,
61             time_zone => 'Asia/Amman'
62             );
63             $dt->truncate(to => 'month'); # Runtime error
64              
65             DateTime module throws runtime error, because time between 00:00 - 00:59
66             01/04/2011 in 'Asia/Amman' did not exist. DateTimeX::Period, on the other hand,
67             provides get_start method, that returns 01:00 01/04/2011, as that is when month
68             started. See unit tests for more example that shows that even truncating to
69             hours can be unsafe!
70              
71             2. Assume for whatever reason you need to add a day in your code.
72             Unfortunately, DateTime is unsafe for that:
73              
74             use DateTime;
75             my $dt = DateTime->new(
76             year =>2010,
77             month => 3,
78             day => 13,
79             minute => 5,
80             time_zone => 'America/Goose_Bay',
81             );
82             $dt->add(days => 1); # Runtime error!
83              
84             Again, 00:05 14/03/2010 did not exist in 'America/Goose_Bay', hence the
85             runtime error.
86              
87             3. Assume you are running critical application that needs to get epoch!
88             Conveniently DateTime has epoch() and for whatever reason you need to perform
89             some operations, such as these:
90              
91             use DateTime;
92             my $dt = DateTime->new(
93             year=> 2013,
94             month => 10,
95             day => 26,
96             hour => 23,
97             minute => 59,
98             second => 59,
99             time_zone => 'Atlantic/Azores',
100             );
101             $dt->add( seconds => 1 ); # 2013-10-27T00:00:00 same
102             print $dt->epoch(); # 1382832000 diff!!!
103             $dt->truncate(to => 'hour'); # 2013-10-27T00:00:00 same
104             print $dt->epoch(); # 1382835600 diff!!!
105              
106             Due to DST, 00:00 occurred twice. DateTime documentation classifies this as
107             ambiguous and always returns later time! Whereas get_start('hour') would have
108             returned correct epoch.
109              
110             =cut
111              
112             # Valid period keys and labels in preserved order
113             my @period_lookup = (
114             '10 minutes', '10 minutes',
115             'hour' , 'Hour' ,
116             'day' , 'Day' ,
117             'week' , 'Week' ,
118             'month' , 'Month'
119             );
120             my ( @ordered_periods, %period_labels );
121             while (@period_lookup) {
122             my $key = shift @period_lookup;
123             my $name = shift @period_lookup;
124             push(@ordered_periods, $key);
125             $period_labels{$key} = $name;
126             }
127              
128             =head1 METHODS
129              
130             =head2 get_start($period)
131              
132             Returns DateTime object with the start of the given period.
133              
134             The start date/time depends in which context period is provided:
135             - if it's a day, than midnight of that day
136             - if it's a week, than Monday at midnight of that week
137             - if it's a month, than 1st day at midnight of that month
138             - and etc.
139              
140             =cut
141              
142             sub get_start
143             {
144 59     59 1 552931 my ( $self, $period ) = @_;
145              
146             # Unfortunately by design DateTime mutates original object, hence cloning it
147 59         218 my $dt = $self->clone();
148              
149 59 100       1019 if ( $period eq '10 minutes' )
    100          
    100          
    100          
    100          
150             {
151 20         84 $dt->truncate( to => 'minute')->subtract(minutes => $dt->minute % 10);
152             # Perl DateTime library always returns later date, when date occurs
153             # twice despite it has ability not to do that. Following while loop
154             # checks that start of the 10 minutes period would not be later then
155             # original object.
156 20         13498 while ( $dt->epoch > $self->epoch )
157             {
158 6         3696 $dt->subtract( minutes => 10 );
159             }
160 20         1224 return $dt;
161             } elsif ( $period eq 'hour') {
162             # truncate to hours is not safe too!!! think of this test case:
163             # DateTime->from_epoch(epoch => 1268539500,time_zone => 'America/Goose_Bay')
164             # ->truncate( to => 'hour' );
165             #
166             # This initialises DateTime object from epoch 1268539500, which
167             # corresponds to 2010-03-14 01:05:00, then tries to truncate to hours,
168             # but fails/dies, because in some locations such as Newfoundland and
169             # Labrador, i.e. ( America/St_Johns ) ( America/Goose_Bay ) on
170             # 2010-03-14 clocks moved from 00:01 to 01:01.
171             # This library fixes it, by getting start of hour as 00:00 and the end
172             # of period 'hour' as 02:00, because 00:01 - 01:01 did not exist.
173             try {
174 8     8   245 $dt->truncate( to => 'hour' );
175             } catch {
176 2     2   479 $dt->subtract( minutes => $dt->minute );
177 8         79 };
178             # same reason as with minutes.
179 8         3434 while ($dt->epoch > $self->epoch )
180             {
181 1         21 $dt->subtract( hours => 1 );
182             }
183 8         918 return $dt;
184             } elsif ( $period eq 'day') {
185             try {
186 13     13   425 $dt->truncate( to => 'day' );
187             } catch {
188 2     2   517 $dt->_get_safe_start('day');
189 13         107 };
190 13         4464 return $dt;
191             } elsif ( $period eq 'week') {
192             try {
193 9     9   280 $dt->truncate( to => 'week' );
194             } catch {
195 3     3   2693 $dt->_get_safe_start('week');
196 9         75 };
197 9         4908 return $dt;
198             } elsif ( $period eq 'month') {
199             try {
200 7     7   252 $dt->truncate( to => 'month' );
201             } catch {
202 2     2   773 $dt->_get_safe_start('month');
203 7         72 };
204 7         1831 return $dt;
205             } else {
206 2         41 croak "found unknown period '$period'";
207             }
208             }
209              
210             =head2 get_end($period)
211              
212             Returns DateTime object with end of the given period, which is same as start
213             of the next period.
214              
215             The end date/time depends in which context period is provided:
216             - if it's a day, than midnight of the next day
217             - if it's a week, than Monday at midnight of the following week
218             - if it's a month, than 1st day at midnight of the following month
219             - and etc.
220              
221             In cases where midnight does not exist, the start of those periods are not at
222             midnight, but this should not affect the end of the period, which is the same
223             as the start of the next period. If it happens to be not at midnight, which
224             might happen in case of 'day', 'week' or 'month' try to truncate, if it fails
225             gracefully fallback to another algorithm.
226              
227             =cut
228              
229             sub get_end
230             {
231 25     25 1 39715 my ( $self, $period ) = @_;
232              
233             # Get the start of the period
234 25         72 my $dt = $self->get_start($period);
235              
236             # Return start of the period + its duration
237 24 100       139 if ( $period eq '10 minutes' )
    100          
    100          
    100          
    50          
238             {
239 4         16 return $dt->add( minutes => 10 );
240             } elsif ( $period eq 'hour') {
241 5         17 return $dt->add( hours => 1 );
242             } elsif ( $period eq 'day') {
243             try {
244 8     8   264 $dt->add( days => 1 );
245 4 100       3292 if ($dt->hour() + $dt->minute() + $dt->second > 0)
246             {
247 1         16 $dt->truncate( to => 'day' );
248             }
249             } catch {
250 4     4   1004 $dt->_get_safe_end('day');
251 8         67 };
252 8         615 return $dt;
253             } elsif ( $period eq 'week') {
254             try {
255 4     4   107 $dt->add( weeks => 1 );
256 3 100       2195 if ($dt->hour() + $dt->minute() + $dt->second > 0)
257             {
258 1         16 $dt->truncate( to => 'week' );
259             }
260             } catch {
261 1     1   205 $dt->_get_safe_end('week');
262 4         32 };
263 4         838 return $dt;
264             } elsif ( $period eq 'month') {
265             try {
266 3     3   78 $dt->add( months => 1 );
267 2 100       1460 if ($dt->hour() + $dt->minute() + $dt->second > 0)
268             {
269 1         18 $dt->truncate( to => 'month' );
270             }
271             } catch {
272 1     1   243 $dt->_get_safe_end('month');
273 3         26 };
274 3         488 return $dt;
275             } else {
276 0         0 croak "found unknown period '$period'";
277             }
278             }
279              
280             =head2 get_period_keys()
281              
282             Returns all period keys in preserved order.
283              
284             =cut
285              
286             sub get_period_keys
287             {
288 1     1 1 934 my ( $self ) = @_;
289              
290 1         6 return \@ordered_periods;
291             }
292              
293             =head2 get_period_label($key)
294              
295             Returns period label.
296              
297             =cut
298              
299             sub get_period_label
300             {
301 6     6 1 285 my ( $self, $key ) = @_;
302 6 100       26 croak "found unknown key '$key'" if (not exists $period_labels{$key} );
303              
304 5         22 return $period_labels{$key};
305             }
306              
307             # Very slow, though necessary fallback algorithms
308             # Provides method to safely get start of day, week and month
309             sub _get_safe_start
310             {
311 7     7   17 my ( $dt, $period ) = @_;
312              
313 7 100       34 if ( $period eq 'day' ) {
    100          
    50          
314 2         7 my $cur_day = $dt->day();
315              
316 2         19 while ($cur_day == $dt->day()) {
317 4         1573 $dt->subtract( minutes => 5 );
318             }
319             } elsif ( $period eq 'week' ) {
320 3         16 my $cur_week = $dt->week();
321              
322 3         71 while ($cur_week == $dt->week()) {
323 870         688837 $dt->subtract( minutes => 5 );
324             }
325             } elsif ( $period eq 'month' ) {
326 2         8 my $cur_month = $dt->month();
327              
328 2         19 while ($cur_month == $dt->month()) {
329 4         2084 $dt->subtract( minutes => 5 );
330             }
331             } else {
332 0         0 croak "found unknown period '$period'";
333             }
334              
335 7         5376 $dt->add(minutes => 5);
336 7         4580 return $dt->get_start('10 minutes');
337             }
338              
339             # Provides safe methods to get end of the hour, day, week and month
340             sub _get_safe_end
341             {
342 6     6   15 my ( $dt, $period ) = @_;
343              
344 6 50       41 if ( $period eq 'hour' ) {
    100          
    100          
    50          
345 0         0 my $cur_hour = $dt->hour();
346              
347 0         0 while ( $cur_hour == $dt->hour() ) {
348 0         0 $dt->add( minutes => 5 );
349             }
350             } elsif ( $period eq 'day' ) {
351 4         15 my $cur_day = $dt->day();
352              
353 4         26 while ( $cur_day == $dt->day() ) {
354 1152         792629 $dt->add( minutes => 5 );
355             }
356             } elsif ( $period eq 'week' ) {
357 1         3 my $cur_week = $dt->week();
358              
359 1         24 while ( $cur_week == $dt->week() ) {
360 2016         1292481 $dt->add( minutes => 5 );
361             }
362             } elsif ( $period eq 'month' ) {
363 1         3 my $cur_month = $dt->month();
364              
365 1         9 while ( $cur_month == $dt->month() ) {
366 8928         5659352 $dt->add( minutes => 5 );
367             }
368             } else {
369 0         0 croak "found unknown period '$period'";
370             }
371              
372 6         4135 return $dt->get_start('10 minutes');
373             }
374              
375             =head1 CAVEATS
376              
377             Start of the week is always Monday.
378              
379             =head1 BUGS
380              
381             Please report any bugs or feature requests to L.
382              
383             =head1 SUPPORT
384              
385             You can find documentation for this module with the perldoc command.
386              
387             perldoc DateTimeX::Period
388              
389             =head1 ACKNOWLEDGEMENTS
390              
391             This module has been written by Vytas Dauksa .
392              
393             =head1 COPYRIGHT AND LICENSE
394              
395             Copyright (C) 2014, Smoothwall.
396              
397             This program is free software, you can redistribute it and/or modify it under
398             the terms of the Artistic License version 2.0.
399              
400             =cut
401              
402             1; # End of DateTimeX::Period