File Coverage

blib/lib/DateTime/TimeZone/OlsonDB/Observance.pm
Criterion Covered Total %
statement 32 183 17.4
branch 4 86 4.6
condition 0 31 0.0
subroutine 12 30 40.0
pod 0 19 0.0
total 48 349 13.7


line stmt bran cond sub pod time code
1             package DateTime::TimeZone::OlsonDB::Observance;
2              
3 13     13   87 use strict;
  13         34  
  13         527  
4 13     13   65 use warnings;
  13         29  
  13         650  
5 13     13   72 use namespace::autoclean;
  13         26  
  13         100  
6              
7             our $VERSION = '2.67';
8              
9 13     13   1201 use DateTime::Duration;
  13         24  
  13         358  
10 13     13   61 use DateTime::TimeZone::OlsonDB;
  13         24  
  13         308  
11 13     13   63 use DateTime::TimeZone::OlsonDB::Change;
  13         54  
  13         659  
12 13     13   82 use List::Util 1.33 qw( any first );
  13         366  
  13         38380  
13              
14             sub new {
15 0     0 0 0 my $class = shift;
16 0         0 my %p = @_;
17              
18 0   0     0 $p{until} ||= q{};
19             $p{$_} ||= 0
20 0   0     0 for qw( offset_from_std last_offset_from_std last_offset_from_utc );
21              
22             my $offset_from_utc
23             = $p{gmtoff} =~ m/^[+-]?\d?\d$/ # only hours? need to handle specially
24             ? 3600 * $p{gmtoff}
25 0 0       0 : DateTime::TimeZone::offset_as_seconds( $p{gmtoff} );
26              
27             my $offset_from_std
28 0         0 = DateTime::TimeZone::offset_as_seconds( $p{offset_from_std} );
29              
30 0         0 my $last_offset_from_utc = delete $p{last_offset_from_utc};
31 0         0 my $last_offset_from_std = delete $p{last_offset_from_std};
32              
33             my $self = bless {
34             %p,
35             offset_from_utc => $offset_from_utc,
36             offset_from_std => $offset_from_std,
37 0         0 until => [ split /\s+/, $p{until} ],
38             }, $class;
39              
40             $self->{first_rule}
41 0         0 = $self->_first_rule( $last_offset_from_utc, $last_offset_from_std );
42              
43 0 0       0 if ( $p{utc_start_datetime} ) {
44             $offset_from_std += $self->{first_rule}->offset_from_std
45 0 0       0 if $self->{first_rule};
46              
47 0         0 my $local_start_datetime = $p{utc_start_datetime}->clone;
48              
49 0         0 $local_start_datetime += DateTime::Duration->new(
50             seconds => $offset_from_utc + $offset_from_std );
51              
52 0         0 $self->{local_start_datetime} = $local_start_datetime;
53             }
54              
55 0         0 return $self;
56             }
57              
58 920 50   920 0 3300 sub offset_from_utc { $_[0]->{offset_from_utc} || 0 }
59 1380 50   1380 0 6905 sub offset_from_std { $_[0]->{offset_from_std} || 0 }
60 460     460 0 1276 sub total_offset { $_[0]->offset_from_utc + $_[0]->offset_from_std }
61              
62             sub offset_from_utc_as_hm {
63 0     0 0 0 my $offset = $_[0]->offset_from_utc;
64 0         0 my $h = int( $offset / 3600 );
65 0         0 my $m = ( $offset % 3600 ) / 60;
66 0         0 return sprintf( '%02d:%02d', $h, $m );
67             }
68              
69             sub offset_from_std_as_hm {
70 0     0 0 0 my $offset = $_[0]->offset_from_std;
71 0         0 my $h = int( $offset / 3600 );
72 0         0 my $m = ( $offset % 3600 ) / 60;
73 0         0 return sprintf( '%02d:%02d', $h, $m );
74             }
75              
76 0     0 0 0 sub rules { @{ $_[0]->{rules} } }
  0         0  
77 0     0 0 0 sub first_rule { $_[0]->{first_rule} }
78              
79             ## no critic (Subroutines::ProhibitBuiltinHomonyms)
80 460     460 0 1300 sub format { $_[0]->{format} }
81             ## use critic
82              
83 0     0 0 0 sub utc_start_datetime { $_[0]->{utc_start_datetime} }
84 0     0 0 0 sub local_start_datetime { $_[0]->{local_start_datetime} }
85              
86             sub formatted_short_name {
87 460     460 0 4180 my $self = shift;
88 460         14160 my $letter = shift;
89 460         872 my $rule = shift;
90              
91 460         1401 my $format = $self->format;
92 460 50       2261 return $format unless $format =~ /%/;
93              
94 460 50       1378 if ( $format eq '%z' ) {
95 0         0 return $self->offset_as_z_format($rule);
96             }
97              
98 460         3769 return sprintf( $format, $letter );
99             }
100              
101             sub offset_as_z_format {
102 0     0 0   my $self = shift;
103 0           my $rule = shift;
104              
105 0           my $offset = $self->total_offset;
106 0 0         $offset += $rule->offset_from_std if $rule;
107 0 0         my $sign = $offset < 0 ? '-' : '+';
108 0           $offset = abs($offset);
109 0           my $h = int( $offset / 3600 );
110 0           my $m = ( $offset % 3600 ) / 60;
111 0 0         if ( $m == 0 ) {
112 0           return sprintf( '%s%02d', $sign, $h );
113             }
114 0           return sprintf( '%s%02d%02d', $sign, $h, $m );
115             }
116              
117             sub expand_from_rules {
118 0     0 0   my $self = shift;
119 0           my $zone = shift;
120              
121             # real max is year + 1 so we include max year
122 0           my $max_year = (shift) + 1;
123              
124 0           my $min_year;
125              
126 0 0         if ( $self->utc_start_datetime ) {
127 0           $min_year = $self->utc_start_datetime->year;
128             }
129             else {
130              
131             # There is at least one time zone that has an infinite
132             # observance, but that observance has rules that only start at
133             # a certain point - Pacific/Chatham
134              
135             # In this case we just find the earliest rule and start there
136              
137             $min_year
138 0           = ( sort { $a <=> $b } map { $_->min_year } $self->rules )[0];
  0            
  0            
139             }
140              
141 0           my $until = $self->until( $zone->last_change->offset_from_std );
142 0 0         if ($until) {
143 0           $max_year = $until->year;
144             }
145             else {
146              
147             # Some zones, like Asia/Tehran, have a predefined fixed set of
148             # rules that go well into the future (2037 for Asia/Tehran)
149 0           my $max_rule_year = 0;
150 0           foreach my $rule ( $self->rules ) {
151 0 0 0       $max_rule_year = $rule->max_year
152             if $rule->max_year && $rule->max_year > $max_rule_year;
153             }
154              
155 0 0         $max_year = $max_rule_year if $max_rule_year > $max_year;
156             }
157              
158 0           foreach my $year ( $min_year .. $max_year ) {
159 0           my @rules = $self->_sorted_rules_for_year($year);
160              
161 0           for my $rule (@rules) {
162 0           my $dt = $rule->utc_start_datetime_for_year(
163             $year,
164             $self->offset_from_utc, $zone->last_change->offset_from_std
165             );
166              
167             next
168 0 0 0       if $self->utc_start_datetime
169             && $dt <= $self->utc_start_datetime;
170              
171             ## no critic (Variables::ProhibitReusedNames)
172 0           my $until = $self->until( $zone->last_change->offset_from_std );
173              
174 0 0 0       next if $until && $dt >= $until;
175              
176 0           my $change = DateTime::TimeZone::OlsonDB::Change->new(
177             type => 'rule',
178             utc_start_datetime => $dt,
179             local_start_datetime => $dt + DateTime::Duration->new(
180             seconds => $self->total_offset + $rule->offset_from_std
181             ),
182             short_name =>
183             $self->formatted_short_name( $rule->letter, $rule ),
184             observance => $self,
185             rule => $rule,
186             );
187              
188 0 0         if ($DateTime::TimeZone::OlsonDB::DEBUG) {
189             ## no critic (InputOutput::RequireCheckedSyscalls)
190 0           print "Adding rule change ...\n";
191              
192 0           $change->_debug_output;
193             }
194              
195 0           $zone->add_change($change);
196             }
197             }
198             }
199              
200             sub _sorted_rules_for_year {
201 0     0     my $self = shift;
202 0           my $year = shift;
203              
204             ## no critic (BuiltinFunctions::ProhibitComplexMappings)
205             my @rules = (
206 0           map { $_->[0] }
207 0           sort { $a->[1] <=> $b->[1] }
208             map {
209 0           my $dt = $_->utc_start_datetime_for_year(
210             $year,
211             $self->offset_from_utc, 0
212             );
213 0           [ $_, $dt ]
214             }
215             grep {
216 0 0 0       $_->min_year <= $year
  0            
217             && ( ( !$_->max_year ) || $_->max_year >= $year )
218             } $self->rules
219             );
220              
221 0           my %rules_by_month;
222 0           for my $rule (@rules) {
223 0           push @{ $rules_by_month{ $rule->month() } }, $rule;
  0            
224             }
225              
226             # In some cases we have both a "max year" rule and a "this year" rule for
227             # a given month's change. In that case, we want to pick the more specific
228             # ("this year") rule, not apply both. This only matters for zones that
229             # have a winter transition that follows the Islamic calendar to deal with
230             # Ramadan. So far this has happened with Cairo, El_Aaiun, and other zones
231             # in northern Africa.
232 0           my @final_rules;
233 0           for my $month ( sort { $a <=> $b } keys %rules_by_month ) {
  0            
234 0           push @final_rules, @{ $rules_by_month{$month} };
  0            
235             }
236              
237 0           return @final_rules;
238             }
239              
240             ## no critic (Subroutines::ProhibitBuiltinHomonyms)
241             sub until {
242 0     0 0   my $self = shift;
243 0   0       my $offset_from_std = shift || $self->offset_from_std;
244              
245 0 0         return unless defined $self->until_year;
246              
247 0           my $utc = DateTime::TimeZone::OlsonDB::utc_datetime_for_time_spec(
248             spec => $self->until_time_spec,
249             year => $self->until_year,
250             month => $self->until_month,
251             day => $self->until_day,
252             offset_from_utc => $self->offset_from_utc,
253             offset_from_std => $offset_from_std,
254             );
255              
256 0           return $utc;
257             }
258             ## use critic
259              
260 0     0 0   sub until_year { $_[0]->{until}[0] }
261              
262             sub until_month {
263 0 0   0 0   return 1 unless defined $_[0]->{until}[1];
264 0           return $DateTime::TimeZone::OlsonDB::MONTHS{ $_[0]->{until}[1] };
265             }
266              
267             sub until_day {
268 0 0   0 0   return 1 unless defined $_[0]->{until}[2];
269             my ( undef, $day ) = DateTime::TimeZone::OlsonDB::parse_day_spec(
270 0           $_[0]->{until}[2],
271             $_[0]->until_month,
272             $_[0]->until_year,
273             );
274 0           return $day;
275             }
276              
277             sub until_time_spec {
278 0 0   0 0   defined $_[0]->{until}[3] ? $_[0]->{until}[3] : '00:00:00';
279             }
280              
281             ## no critic (Subroutines::ProhibitExcessComplexity)
282             sub _first_rule {
283 0     0     my $self = shift;
284 0           my $last_offset_from_utc = shift;
285 0           my $last_offset_from_std = shift;
286              
287 0 0         return unless $self->rules;
288              
289 0 0         my $date = $self->utc_start_datetime
290             or return $self->_first_no_dst_rule;
291              
292 0           my @rules = $self->rules;
293              
294 0           my %possible_rules;
295              
296 0           my $year = $date->year;
297 0           foreach my $rule (@rules) {
298              
299             # We need to look at what the year _would_ be if we added the
300             # rule's offset to the UTC date. Otherwise we can end up with
301             # a UTC date in year X, and a rule that starts in _local_ year
302             # X + 1, where that rule really does apply to that UTC date.
303 0           my $temp_year
304             = $date->clone->add(
305             seconds => $self->offset_from_utc + $rule->offset_from_std )
306             ->year;
307              
308             # Save the highest value
309 0 0         $year = $temp_year if $temp_year > $year;
310              
311 0 0         next if $rule->min_year > $temp_year;
312              
313 0           $possible_rules{$rule} = $rule;
314             }
315              
316 0           my $earliest_year = $year - 1;
317 0           foreach my $rule (@rules) {
318 0 0         $earliest_year = $rule->min_year
319             if $rule->min_year < $earliest_year;
320             }
321              
322             # figure out what date each rule would start on _if_ that rule
323             # were applied to this current observance. this could be a rule
324             # that started much earlier, but is only now active because of an
325             # observance switch. An obnoxious example of this is
326             # America/Phoenix in 1944, which applies the US rule in April,
327             # thus (re-)instating the "war time" rule from 1942. Can you say
328             # ridiculous crack-smoking stupidity?
329 0           my @rule_dates;
330 0           foreach my $y ( $earliest_year .. $year ) {
331             RULE:
332 0           foreach my $rule ( values %possible_rules ) {
333              
334             # skip rules that can't have applied the year before the
335             # observance started.
336 0 0         if ( $rule->min_year > $y ) {
337             ## no critic (InputOutput::RequireCheckedSyscalls)
338 0 0         print 'Skipping rule beginning in ', $rule->min_year,
339             ". Year is $y.\n"
340             if $DateTime::TimeZone::OlsonDB::DEBUG;
341              
342 0           next RULE;
343             }
344              
345 0 0 0       if ( $rule->max_year && $rule->max_year < $y ) {
346             ## no critic (InputOutput::RequireCheckedSyscalls)
347 0 0         print 'Skipping rule ending in ', $rule->max_year,
348             ". Year is $y.\n"
349             if $DateTime::TimeZone::OlsonDB::DEBUG;
350              
351 0           next RULE;
352             }
353              
354 0           my $rule_start = $rule->utc_start_datetime_for_year(
355             $y,
356             $last_offset_from_utc, $last_offset_from_std
357             );
358              
359 0           push @rule_dates, [ $rule_start, $rule ];
360             }
361             }
362              
363 0           @rule_dates = sort { $a->[0] <=> $b->[0] } @rule_dates;
  0            
364              
365             ## no critic (InputOutput::RequireCheckedSyscalls)
366 0 0         print "Looking for first rule ...\n"
367             if $DateTime::TimeZone::OlsonDB::DEBUG;
368 0 0         print ' Observance starts: ', $date->datetime, "\n\n"
369             if $DateTime::TimeZone::OlsonDB::DEBUG;
370             ## use critic
371              
372             # ... look through the rules to see if any are still in
373             # effect at the beginning of the observance
374              
375             ## no critic (ControlStructures::ProhibitCStyleForLoops)
376 0           for ( my $x = 0; $x < @rule_dates; $x++ ) {
377 0           my ( $dt, $rule ) = @{ $rule_dates[$x] };
  0            
378             my ( $next_dt, $next_rule )
379 0 0         = $x < @rule_dates - 1 ? @{ $rule_dates[ $x + 1 ] } : undef;
  0            
380              
381 0 0 0       next if $next_dt && $next_dt < $date;
382              
383             ## no critic (InputOutput::RequireCheckedSyscalls)
384 0 0         print ' This rule starts: ', $dt->datetime, "\n"
385             if $DateTime::TimeZone::OlsonDB::DEBUG;
386              
387 0 0 0       print ' Next rule starts: ', $next_dt->datetime, "\n"
388             if $next_dt && $DateTime::TimeZone::OlsonDB::DEBUG;
389              
390 0 0 0       print " No next rule\n\n"
391             if !$next_dt && $DateTime::TimeZone::OlsonDB::DEBUG;
392             ## use critic
393              
394 0 0         if ( $dt <= $date ) {
395 0 0         if ($next_dt) {
396 0 0         return $rule if $date < $next_dt;
397 0 0         return $next_rule if $date == $next_dt;
398             }
399             else {
400 0           return $rule;
401             }
402             }
403             }
404              
405             # If this observance has rules, but the rules don't have any
406             # defined changes until after the observance starts, we get the
407             # earliest standard time rule and use it. If there is none, shit
408             # blows up (but this is not the case for any time zones as of
409             # 2009a). I really, really hate the Olson database a lot of the
410             # time! Could this be more arbitrary?
411 0           my $std_time_rule = $self->_first_no_dst_rule;
412              
413 0 0         die
414             q{Cannot find a rule that applies to the observance's date range and cannot find a rule without DST to apply}
415             unless $std_time_rule;
416              
417 0           return $std_time_rule;
418             }
419             ## use critic
420              
421             sub _first_no_dst_rule {
422 0     0     my $self = shift;
423              
424 0     0     return first { !$_->offset_from_std }
425 0           sort { $a->min_year <=> $b->min_year } $self->rules;
  0            
426             }
427              
428             1;