Say I want to create a daily planner, and I want to divide the day into 15 minute chunks.
Easy, right? Just start at midnight, and... Wrong! In America/Sao_Paulo, one day each year starts at 01:00 because of Daylight Saving Time changes.
Given a time zone and a date, how does one find the epoch time at which the day starts?
My first thought was to use the following, but it assumes each day has a 23:59. That's probably no better of an assumption than assuming each day has a midnight.
perl -MDateTime -E'
say
DateTime->new( year => 2013, month => 10, day => 20 )
->subtract( days => 1 )
->set( hour => 23, minute => 59 )
->set_time_zone("America/Sao_Paulo")
->add( minutes => 1 )
->strftime("%H:%M");
'
01:00
Is there a more robust or more direct alternative?
You'd think this is something that needs to be done commonly! I suspect there's a lot of buggy code out there...
Here's a solution that's coded with the intention of trying to get it incorporated into DateTime.
use strict;
use warnings;
use DateTime qw( );
use DateTime::TimeZone qw( );
# Assumption:
# There is no dt to which one can add time
# to obtain a dt with an earlier date.
sub day_start {
my $tz = shift;
my $dt = shift;
my $local_rd_days = ( $dt->local_rd_values() )[0];
my $seconds = $local_rd_days * 24*60*60;
my $min_idx;
if ( $seconds < $tz->max_span->[DateTime::TimeZone::LOCAL_END] ) {
$min_idx = 0;
} else {
$min_idx = @{ $tz->{spans} };
$tz->_generate_spans_until_match( $dt->utc_year()+1, $seconds, 'local' );
}
my $max_idx = $#{ $tz->{spans} };
my $utc_rd_days;
my $utc_rd_secs;
while (1) {
my $current_idx = int( ( $min_idx + $max_idx )/2 );
my $current = $tz->{spans}[$current_idx];
if ( $seconds < $current->[DateTime::TimeZone::LOCAL_START] ) {
$max_idx = $current_idx - 1;
}
elsif ( $seconds >= $current->[DateTime::TimeZone::LOCAL_END] ) {
$min_idx = $current_idx + 1;
}
else {
my $offset = $current->[DateTime::TimeZone::OFFSET];
# In case of overlaps, always prefer earlier span.
if ($current->[DateTime::TimeZone::IS_DST] && $current_idx) {
my $prev = $tz->{spans}[$current_idx-1];
$offset = $prev->[DateTime::TimeZone::OFFSET]
if $seconds >= $prev->[DateTime::TimeZone::LOCAL_START]
&& $seconds < $prev->[DateTime::TimeZone::LOCAL_END];
}
$utc_rd_days = $local_rd_days;
$utc_rd_secs = -$offset;
DateTime->_normalize_tai_seconds($utc_rd_days, $utc_rd_secs);
last;
}
if ($min_idx > $max_idx) {
$current_idx = $min_idx;
$current = $tz->{spans}[$current_idx];
if (int( $current->[DateTime::TimeZone::LOCAL_START] / (24*60*60) ) != $local_rd_days) {
my $err = 'Invalid local time for date';
$err .= " in time zone: " . $tz->name;
$err .= "\n";
die $err;
}
$utc_rd_secs = $current->[DateTime::TimeZone::UTC_START] % (24*60*60);
$utc_rd_days = int( $current->[DateTime::TimeZone::UTC_START] / (24*60*60) );
last;
}
}
my ($year, $month, $day) = DateTime->_rd2ymd($utc_rd_days);
my ($hour, $minute, $second) = DateTime->_seconds_as_components($utc_rd_secs);
return
$dt
->_new_from_self(
year => $year,
month => $month,
day => $day,
hour => $hour,
minute => $minute,
second => $second,
time_zone => 'UTC',
)
->set_time_zone($tz);
}
Test:
sub new_date {
my $y = shift;
my $m = shift;
my $d = shift;
return DateTime->new(
year => $y, month => $m, day => $d,
@_,
hour => 0, minute => 0, second => 0, nanosecond => 0,
time_zone => 'floating'
);
}
{
# No midnight.
my $tz = DateTime::TimeZone->new( name => 'America/Sao_Paulo' );
my $dt = day_start($tz, new_date(2013, 10, 20));
print($dt->iso8601(), "\n"); # 2013-10-20T01:00:00
$dt->subtract( seconds => 1 );
print($dt->iso8601(), "\n"); # 2013-10-19T23:59:59
}
{
# Two midnights.
my $tz = DateTime::TimeZone->new( name => 'America/Havana' );
my $dt = day_start($tz, new_date(2013, 11, 3));
print($dt->iso8601(), "\n"); # 2013-11-03T00:00:00
$dt->subtract( seconds => 1 );
print($dt->iso8601(), "\n"); # 2013-11-02T23:59:59
}
A practical example,
sub today_as_floating {
return
DateTime
->now( @_ )
->set_time_zone('floating')
->truncate( to => 'day' );
}
{
my $tz = DateTime::TimeZone->new( name => 'local' );
my $dt = today_as_floating( time_zone => $tz );
$dt = day_start($tz, $dt);
print($dt->iso8601(), "\n");
}
A reasonable approach would be to start at 12:00 PM (noon) on that day, and work backwards incrementally until the date changed. The same going forward to find the end of the day.
Noon is appropriate, because (AFAIK) all time zones that have DST changes transition in the middle of the night, to minimize the impact on human beings. Presumably, the vast majority of people are awake during the day, so governments would be foolish to set DST changes during business hours.
You would want to move in 15 minute increments to cover all bases. There are some time zones with :30 or :45 minute offsets, and some that only change by 30 minutes for DST.
Now if you are going back into antiquity, this isn't the best solution because many time zones had adjustments for other reasons than DST - such as initial synchronization with UTC, which could be by some odd minutes or seconds value. So this should work fine with reasonably present dates, but not for all past dates.
If you want something that is less linear, then the algorithm would have to determine the interval of the boundaries for the time zone rule that the date fell into, then use those to check if they fall on the day in question or not. In the source code for Datetime::TimeZone
, I see that it defines an internal concept of a "span". You could use DateTime::TimeZone->_span_for_datetime
to find the span that the date in question fell into, and then check the start and end dates from there.
I am not a Perl programmer, so I'll leave that exercise to you or someone else. Besides, I checked and the values in the span don't appear to be unix timestamps, so I'm not quite sure how to take it from there - and they appear to be undocumented/internal so I don't think that's necessarily a good idea in Perl anyway.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With