Peter
Peter Campbell Smith

Imaginaries and the daily grind

Weekly challenge 178 — 15 August 2022

Week 178 - 15 Aug 2022

Task 2

Task — Business date

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.

Examples


Example 1:
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.

Example 2:
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.

Analysis

This is a task which I have often had to address as a project manager: Fred has a workpackage estimated to take 200 hours. If he starts now, when should he finish? Of course in real life there are complications - bank holidays, annual leave, sick leave, distractions, overtime and so on. However, again in real life, precision is not required, not least because the 200 hour estimate is probably far from exactly correct.

But today's task demands, I think, precision. After a few false starts my submitted algorithm looks like this.

We start with $timestamp and $duration. Let's assume that $timestamp falls within the working week (so not 2am on a Saturday, for example). A little thought suggests that it would be much easier if $timestamp were 9am on a Monday. So here goes:

Move $timestamp back to 9am on the preceding Monday, and increase $duration by the number of working hours we've moved it back. That will be 9 hours for every full day plus the time span between 9am and $timestamp. So for example, if $timestamp is 11:00 on Wednesday, we are increasing duration by 18 hours for Monday and Tuesday plus 2 hours on Wednesday.

We could then:

  • divide $duration by 45 (working hours per week) to get a number of full weeks
  • divide the remainder by 9 to get the number of extra days (9 hrs per day)
  • and the remainder is the time period after 9am on the date that results.

All that date arithmetic can easily be done using the epoch date (the seconds from 01.01.1970 used by Unix), remembering that a day is 24 * 60 * 60 seconds.

But it doesn't quite work.

It doesn't quite work because of daylight saving time. If the $duration takes us over the date when the clocks go back or forward, we'll be out by an hour. Can we rely on 'Please ignore timezone too' to ignore that?

Being of a cautious frame of mind, I adapted my algorithm slightly. Instead of just working with the epoch date, I treated the date and the time-of-day separately. I have a function date10_add() which takes a date (eg 2022-12-25) and adds an integer number of calendar days, and I used that to move forward by the full weeks and extra days from my algorithm above. As the working day is always 9 hours, even on the days the clocks change, we don't need to worry about that.

There is a slight anomaly to my algorithm. If the $duration ends at 6pm, the algorithm will give 9am on the next working day as the answer. This is of course strictly correct, but perhaps not what is expected.

Try it 

Try running the script with any input:



example: 2022-08-01 10:30



example: 11

Script


#!/usr/bin/perl

# Peter Campbell Smith - 2022-08-15
# PWC 178 task 2

use v5.26;
use utf8;
use warnings;
use Time::Local;

my (@tests, $test, $start, $duration, $d, $m, $y, $h, $i, $s, $date, $day_of_week, 
    $whole_weeks, $whole_days, $end, @t);
             
@tests = (  ['2022-08-17 15:00:00', 93], 
            ['2022-08-17 15:00:00', 45 * 52 + 9], # a year
            ['2023-01-02 09:00:00', 999999],
            ['2022-08-22 18:00:00', 0],
            ['2022-08-17 12:00:00', 1.56]);

for $test (@tests) {
    $start = $test->[0];
    $duration = $test->[1];   # hours
    
    $start =~ m|(....).(..).(..).(..).(..).(..)|;
    ($y, $m, $d, $h, $i, $s) = ($1, $2, $3, $4, $5, $6);
    
    # start must be in working hours
    @t = localtime(timelocal($s, $i, $h, $d, $m - 1, $y - 1900));
    if ($t[6] == 0 or $t[6] == 6 or $h < 9 or ($h >= 18 and $h.$i.$s ne '180000')) {
        say qq[\ntimestamp $start not within working hours];
        next;
    }
    
    # move back to 9am on the starting day
    $date = substr($start, 0, 10);
    $duration += $h - 9 - $i / 60 - $s  / 3600 ;   # revised duration   
    
    # move back to preceding Monday 9am
    @t = localtime(timelocal($s, $i, $h, $d, $m - 1, $y - 1900));
    $day_of_week = $t[6];   # 0 = Sunday
    $date = date10_add($start, 1 - $day_of_week);
    $duration += ($day_of_week - 1) * 9;
        
    # move forward complete weeks
    $whole_weeks = int($duration / 45); 
    $date = date10_add($date, 7 * $whole_weeks);
    $duration -= $whole_weeks * 45;
    
    # and whole days
    $whole_days = int($duration / 9);
    $date = date10_add($date, $whole_days);
    $duration -= $whole_days * 9;

    # and the time of day
    $h = 9 + int($duration);
    $duration -= int($duration);
    $duration *= 3600;  # seconds
    $i = int($duration / 60);
    $s = $duration - $i * 60;
    
    $end = sprintf('%s %02d:%02d:%02d', $date, $h, $i, $s);
    say qq[\nInput:  \$timestamp = $start, \$duration = $test->[1]];
    say qq[Output: $end];   
}

sub date10_add {  #  (date10a, days) -- returns date10 which is days after date10a

    my (@t);
    if ($_[0] =~ m|^(....)-(..)-(..)|) {
        @t = localtime(timelocal(0, 0, 12, $3, $2 - 1, $1 - 1900) + $_[1] * 86400);
        return sprintf('%04d-%02d-%02d', $t[5] + 1900, $t[4] + 1, $t[3]);
    } else {
        return 0;
    }
}
    
    

Output


Input:  $timestamp = 2022-08-17 15:00:00, $duration = 93
Output: 2022-09-01 09:00:00

Input:  $timestamp = 2022-08-17 15:00:00, $duration = 2349
Output: 2023-08-17 15:00:00

Input:  $timestamp = 2023-01-02 09:00:00, $duration = 999999
Output: 2448-11-24 09:00:00

Input:  $timestamp = 2022-08-22 18:00:00, $duration = 0
Output: 2022-08-23 09:00:00

Input:  $timestamp = 2022-08-17 12:00:00, $duration = 1.56
Output: 2022-08-17 13:33:36