ikegami ikegami - 7 months ago 10
Perl Question

Determining the time at which a date starts

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?

Answer

Here's a solution using only DT's public methods:

sub day_start {
   my $dt = shift;
   my $tz = shift;

   # Expect floating or UTC, but let's make sure.
   $dt->set_time_zone('floating');

   $dt->truncate( to => 'day' );

   # This will work except for days without midnight.
   if (eval { $dt->set_time_zone($tz); 1 }) {
      return $dt;
   }

   my $ymd = $dt->ymd();

   my $min_epoch = $dt->epoch() - 48*60*60;
   my $max_epoch = $dt->epoch() + 48*60*60;

   while (1) {
      my $current_epoch = int( ( $min_epoch + $max_epoch )/2 );
      _set_epoch($dt, $current_epoch);
      my $current_ymd = $dt->ymd();

      if ($current_ymd lt $ymd) {
         $min_epoch = $current_epoch + 1;
      } else {
         $dt->subtract( seconds => 1 );
         my $earlier_ymd = $dt->ymd();
         if ($earlier_ymd ge $ymd) {
            $max_epoch = $current_epoch - 1;
         } else {
            _set_epoch($dt, $current_epoch);
            return $dt;
         }
      }
   }
}

# Based on DateTime::from_epoch
sub _set_epoch {
   my ($dt, $epoch) = @_;

   my %args;

   #  # Epoch may come from Time::HiRes, so it may not be an integer.
   #  ($epoch, my $dec) = $epoch =~ /^(-?\d+)?(\.\d+)?/;
   #  $epoch ||= 0;
   #  
   #  $args{nanosecond} = int($dec * 1_000_000_000) if $dec;

   # Note: For very large negative values, this may give a blatantly wrong answer.
   @args{qw( second minute hour day month year )} = gmtime($epoch);
   $args{year} += 1900;
   ++$args{month};

   my $tz = $dt->time_zone();
   $dt->set_time_zone('UTC');
   $dt->set(%args);
   $dt->set_time_zone($tz);
   return $dt;
}

Assumptions:

  • There is no dt to which one can add time to obtain a dt with an earlier date.
  • In no time zone does a date starts more than 48*60*60 seconds before the date starts in UTC.
  • In no time zone does a date starts more than 48*60*60 seconds after the date starts in UTC.

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(new_date(2013, 10, 20), $tz);
    print($dt->epoch, " ", $dt->iso8601, "\n");  # 1382238000 2013-10-20T01:00:00
    $dt->subtract( seconds => 1 );
    print($dt->epoch, " ", $dt->iso8601, "\n");  # 1382237999 2013-10-19T23:59:59
}

{
    # Two midnights.
    my $tz = DateTime::TimeZone->new( name => 'America/Havana' );
    my $dt = day_start(new_date(2013, 11, 3), $tz);
    print($dt->epoch, " ", $dt->iso8601, "\n");  # 1383451200 2013-11-03T00:00:00
    $dt->subtract( seconds => 1 );
    print($dt->epoch, " ", $dt->iso8601, "\n");  # 1383451199 2013-11-02T23:59:59
}