PWC178 - Business Date

TL;DR

On with TASK #2 from The Weekly Challenge #178. Enjoy!

The challenge

You are given $timestamp (date with time) and $duration in hours.

Write a script to find the time that occurs $duration business hours after $timestamp. For the sake of this task, let us assume the working hours is 9am to 6pm, Monday to Friday. Please ignore timezone too.

For example,

Suppose the given timestamp is 2022-08-01 10:30 and the duration is 4 hours.
Then the next business date would be 2022-08-01 14:30.

Similar if the given timestamp is 2022-08-01 17:00 and the duration is 3.5 hours.
Then the next business date would be 2022-08-02 11:30.

The questions

Assumptions, more than questions:

  • it’s OK to consider dates after 1980 (just to be on the safe side) but before 2036 (again, just to be on the safe side)
  • holidays are anyway considered business days - like August 15th is normally holiday in Italy, but whatever in 2020 it’s Monday so it’s a business day.

What to do if the input date is valid, but not a business date itself? I’ll assume that we shift to the beginning of the next business day and start our calculations from there.

The solution

As in everything dates and time, it’s a lot of calculation with that residual tickling sensation that something might have gone wrong. For this reason, I usually take very little steps, making sure to avoid leap second stuff etc.

In Perl, this means using gmtime and timegm (from Time::Local) to do the heavylifting, making sure to add at most one day and using hours in the middle of the day to do this. Cross fingers!

So, without further ado:

#!/usr/bin/env perl
use v5.24;
use warnings;
use experimental 'signatures';
no warnings 'experimental::signatures';
use Time::Local 'timegm';

my $ts       = shift // '2022-08-01 10:30';
my $duration = shift // 4;
say add_bh($ts, $duration);

sub parse_datetime ($dt) {
   my $error = "invalid input timestamp <$dt>\n";
   my ($y, $m, $d, $H, $M) = $dt =~ m{
      \A (\d+) - (\d\d) - (\d\d) \  (\d\d):(\d\d) \z
   }mxs or die $error;
   die $error unless eval {
      timegm(0, $M, $H, $d, $m - 1, $y);
      1;
   };
   return [ $y, $m, $d, $H, $M ];
}

sub add_bh ($timestamp, $duration) {
   state $sod_min =  9 * 60;
   state $eod_min = 18 * 60;
   my $duration_min = int($duration * 60); # in minutes, rounded down

   my $dt = parse_datetime($timestamp);

   # cope with the possibility that the provided timestamp is
   # *outside* the allowed range, move to the beginning of the
   # next business day
   $dt = next_business_day_start($dt) unless is_in_business($dt);
   my $start_min = $dt->[3] * 60 + $dt->[4];

   while ($duration_min > 0) {
      my $available_min = $eod_min - $start_min;
      if ($duration_min >= $available_min) {
         $dt = next_business_day_start($dt);
         $duration_min -= $available_min;
         $start_min = $sod_min;
      }
      else { # we're in the very day!
         my $stop_min = $start_min + $duration_min;
         $dt->[4] = my $M = $stop_min % 60;
         $dt->[3] = ($stop_min - $M) / 60;
         $duration_min = 0;
      }
   }

   return sprintf '%04d-%02d-%02d %02d:%02d', $dt->@*;
}

sub is_in_business ($dt) {
   my ($y, $m, $d, $H, $M) = $dt->@*;
   return if $H < 9 || $H > 17; # 18:00 is out ;)
   my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) =
     gmtime(timegm(0, $M, $H, $d, $m - 1, $y));
   return (0 < $wday && $wday < 6);
} ## end sub is_in_business ($dt)

sub next_business_day_start ($dt) {
   state $day_s = 24 * 60 * 60;
   my ($y, $m, $d) = $dt->@*;
   while ('necessary') {
      my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) =
         gmtime($day_s + timegm(0, 30, 12, $d, $m - 1, $y));
      $year += 1900;
      ++$mon;
      return [$year, $mon, $mday, 9, 0]
         if 0 < $wday && $wday < 6;
      ($y, $m, $d) = ($year, $mon, $mday);
   }
}

To add the duration, we make sure to do the calculations one day at a time. This is like doing multiplications by summing in a loop, and I’m happy with this because there are so many corner cases!

In Raku life is slightly easier thanks to the batteries included of DateTime:

#!/usr/bin/env raku
use v6;

sub MAIN ($ts = '2022-08-01 10:30', $duration = 4) {
   put add-bh($ts, $duration);
}

sub parse_datetime ($dt) {
   my $error = "invalid input timestamp <$dt>\n";
   my $match = $dt ~~ /
      ^ (\d+) '-' (\d\d) '-' (\d\d) ' ' (\d\d) ':' (\d\d) $
   / or die $error;
   my ($y, $m, $d, $H, $M) = $match[0..4].map({0 + $_});
   try {
      CATCH {
         default { die $error }
      }
      return DateTime.new(year => $y, month => $m, day => $d,
         hour => $H, minute => $M);
   }
}

sub is-in-business ($dt) {
   return (
      (9 <= $dt.hour < 18) # 18:00 is out ;)
      &&  (1 <= $dt.day-of-week <= 5)
   );
} ## end sub is_in_business ($dt)

sub next-business-day-start ($dt is copy) {
   loop {
      $dt = $dt.clone(hour => 9, minute => 0).later(day => 1);
      return $dt if (1 <= $dt.day-of-week <= 5);
   }
}

sub add-bh ($timestamp, $duration) {
   state $sod_min =  9 * 60;
   state $eod_min = 18 * 60;
   my $duration_min = ($duration * 60).Int; # in minutes, rounded down

   my $dt = parse_datetime($timestamp);

   # cope with the possibility that the provided timestamp is
   # *outside* the allowed range, move to the beginning of the
   # next business day
   $dt = next-business-day-start($dt) unless is-in-business($dt);
   my $start_min = $dt.hour * 60 + $dt.minute;

   while $duration_min > 0 {
      my $available_min = $eod_min - $start_min;
      if ($duration_min >= $available_min) {
         $dt = next-business-day-start($dt);
         $duration_min -= $available_min;
         $start_min = $sod_min;
      }
      else { # we're in the very day!
         my $stop_min = $start_min + $duration_min;
         my $M = $stop_min % 60;
         my $H = ($stop_min - $M) / 60;
         $dt = $dt.clone(hour => $H, minute => $M);
         $duration_min = 0;
      }
   }

   return '%04d-%02d-%02d %02d:%02d'.sprintf(
      $dt.year, $dt.month, $dt.day, $dt.hour, $dt.minute);
}

Apart from using the DateTime class, it’s the same solution as Perl.

Stay safe!


Comments? Octodon, , GitHub, Reddit, or drop me a line!