Dates and parsing
Weekly challenge 259 — 4 March 2024
Week 259: 4 Mar 2024
You are given a start date and offset counter. Optionally you also get a bank holiday date list. Given a number of days and a start date, return the number of days adjusted to take into account non-banking days. In other words: convert a banking day offset to a calendar day offset.
Non-banking days are weekends and bank holidays.
Example 1 Input: $start_date = '2018-06-28', $offset = 3, $bank_holidays = ['2018-07-03'] Output: '2018-07-04' Thursday bumped to Wednesday (3 day offset, with Monday a bank holiday) Example 2 Input: $start_date = '2018-06-28', $offset = 3 Output: '2018-07-03'
I do enjoy date challenges. I've slightly deviated from the statement of this one because I already have
a function - bank_hols
-
that calculates English bank holiday dates. I wrote it in 2001 since when my programming style has developed
somewhat, but it works.
The tricky dates are of course Good Friday
and Easter Monday: you can see how I calculate those in the comments, a method which I took from an early
paper that later became the book
Calendrical Calculations by
Nachum Dershowitz and Edward Reingold.
With this sort of challenge I've found the best way is just to step forward day by day, discarding any
dates that aren't working days and counting those that are by decrementing $offset
until it is zero.
I work with Unix dates - ie seconds since the start of 1970. Since we're dealing with whole days I start at
noon on the first day and add 86400 seconds to step forward a day. Because of daylight saving time that may
sometimnes end up at 11:00 or 13:00, but that doesn't matter as we're only concerned with the date.
Eliminating Saturdays and Sundays is done using Perl's inbuilt timelocal()
function, which returns
the day-of week in array @d
with $d[6] == 0
being Sunday and $d[6] == 6
being Saturday. That bit's easy.
My bank_hols()
function takes a 4-digit year as its argument and returns an array of bank holidays
in ISO-8601 yyyy-mm-dd format. To answer the
question of whether a date is a bank holiday, I first join()
the holiday dates with a '|' separator
into $hol_string
.
That way I can simply use $ymd_date =~ m|$hol_string|
to identify a holiday. I cache
$hol_string
and only recalculate it if the stepping forward crosses a year boundary.
One could say that this is an inefficient algorithm, but, as you'll see in my worked examples, it copes with an offset of 10 000 days in milliseconds.
#!/usr/bin/perl # Blog: http://ccgi.campbellsmiths.force9.co.uk/challenge use v5.26; # The Weekly Challenge - 2024-03-04 use utf8; # Week 259 - task 1 - Banking day offset use warnings; # Peter Campbell Smith binmode STDOUT, ':utf8'; use Time::Local; banking_day_offset('2024-03-04', 5); banking_day_offset('2024-03-28', 1); # Easter weekend banking_day_offset('2024-01-02', 254); banking_day_offset('1970-01-01', 10000); sub banking_day_offset { my ($start_date, $unix_date, $one_day, $hol_year, @d, $year, @hols, $hol_string, $ymd_date, $offset); ($start_date, $offset) = @_; # initialise say qq[\nInput: \$start_date = '$start_date', \$offset = $offset]; $start_date =~ m|(....)-(..)-(..)|; $unix_date = timelocal(0, 0, 12, $3, $2 - 1, $1 - 1900); $one_day = 86400; # secs in a day $hol_year = 0; # move forward day by day while ($offset > 0) { $unix_date += $one_day; @d = localtime($unix_date); # weekend no good next if ($d[6] == 0 or $d[6] == 6); # bank holiday no good $year = $d[5] + 1900; if ($hol_year != $year) { @hols = bank_hols($year); $hol_string = join('|', @hols); $hol_year = $year; } $ymd_date = sprintf('%04d-%02d-%02d', $year, $d[4] + 1, $d[3]); next if $ymd_date =~ m|$hol_string|; # decrement $offset until 0 $offset --; } say qq[Output: '$ymd_date']; } sub bank_hols { # (year as yyyy) # pjcs - 2001-01-29 my ($year, @hols, $thedate, @d, $x, $dow, $a, $b, $c, $d, $e, $f, $g, $h, $i, $k, $l, $m, $n, $p, $gf); # get unix year $year = $_[0] - 1900; # New Year = first non-weekend day in year $thedate = timelocal(0, 0, 12, 1, 0, $year); ($x, $x, $x, $x, $x, $x, $dow) = localtime($thedate); $thedate += 86400 if $dow == 0; $thedate += 172800 if $dow == 6; @d = localtime($thedate); push @hols, sprintf('%04d-%02d-%02d', $d[5] + 1900, $d[4] + 1, $d[3]); # Good Friday = two days before Easter # easter algorithm # Divide by Quotient Remainder # # the year 19 - a # the year 100 b c # b 4 d e # b + 8 25 f - # b - f + 1 3 g - # 19*a + b - d - g + 15 30 - h # c 4 i k # 32 + 2*e + 2*i - h - k 7 - L # a + 11*h + 22*L 451 m - # h + L - 7*m + 114 31 n p # # then Easter falls on day p+1 of month n ($x, $a) = quorem($year + 1900, 19); ($b, $c) = quorem($year + 1900, 100); ($d, $e) = quorem($b, 4); ($f, $x) = quorem($b + 8, 25); ($g, $x) = quorem($b - $f + 1, 3); ($x, $h) = quorem(19 * $a + $b - $d - $g + 15, 30); ($i, $k) = quorem($c, 4); ($x, $l) = quorem(32 + 2 * $e + 2*$i - $h - $k, 7); ($m, $x) = quorem($a + 11 * $h + 22 * $l, 451); ($n, $p) = quorem($h + $l - 7 * $m + 114, 31); $thedate = timelocal(0, 0, 0, $p + 1, $n - 1, $year) - 172800; @d = localtime($thedate); push @hols, sprintf('%04d-%02d-%02d', $d[5] + 1900, $d[4] + 1, $d[3]); # Easter Monday = three days after Good Friday $thedate += 3 * 86400; @d = localtime($thedate); push @hols, sprintf('%04d-%02d-%02d', $d[5] + 1900, $d[4] + 1, $d[3]); # May Day = first Monday in May $thedate = timelocal(0, 0, 0, 1, 4, $year); ($x, $x, $x, $x, $x, $x, $dow) = localtime($thedate); $dow = -1 if $dow == 6; $thedate += (1 - $dow) * 86400 if $dow <= 0; $thedate += (8 - $dow) * 86400 if $dow >= 2; $thedate += 172800 if $dow == 6; @d = localtime($thedate); push @hols, sprintf('%04d-%02d-%02d', $d[5] + 1900, $d[4] + 1, $d[3]); # Spring bank holiday - last Monday in May $thedate = timelocal(0, 0, 0, 1, 5, $year); ($x, $x, $x, $x, $x, $x, $dow) = localtime($thedate); $thedate -= ($dow - 1) * 86400 if $dow >= 2; $thedate -= ($dow + 6) * 86400 if $dow <= 1; @d = localtime($thedate); push @hols, sprintf('%04d-%02d-%02d', $d[5] + 1900, $d[4] + 1, $d[3]); # Summer bank holiday - last Monday in August $thedate = timelocal(0, 0, 0, 1, 8, $year); ($x, $x, $x, $x, $x, $x, $dow) = localtime($thedate); $thedate -= ($dow - 1) * 86400 if $dow >= 2; $thedate -= ($dow + 6) * 86400 if $dow <= 1; @d = localtime($thedate); push @hols, sprintf('%04d-%02d-%02d', $d[5] + 1900, $d[4] + 1, $d[3]); # Christmas and Boxing Day - 2 weekdays on or after 25 December $thedate = timelocal(0, 0, 12, 24, 11, $year); for $k (0 .. 1) { do { $thedate += 86400; ($x, $x, $x, $x, $x, $x, $dow) = localtime($thedate); } until ($dow >= 1 and $dow <= 5); @d = localtime($thedate); push @hols, sprintf('%04d-%02d-%02d', $d[5] + 1900, $d[4] + 1, $d[3]); } return @hols; } sub quorem { # quorem(a, b) returns (quotient, remainder) of a/b (+ve integers only) return (int $_[0]/$_[1], $_[0]%$_[1]); }
Input: $start_date = '2024-03-04', $offset = 5 Output: '2024-03-11' Input: $start_date = '2024-03-28', $offset = 1 Output: '2024-04-02' Input: $start_date = '2024-01-02', $offset = 254 Output: '2025-01-02' Input: $start_date = '1970-01-01', $offset = 10000 Output: '2109-07-18'
Any content of this website which has been created by Peter Campbell Smith is in the public domain