Doing Date Math on the Command Line - Part II

""

In part II of this series of articles on doing date math from the command line we want to try to solve a problem we noted in part I: passing the date command a date specification something like "the first Monday after some date".

My first thought at solving this was to add 1 to 7 days to the start date and look at the resulting dates to find the one that matches the requested day of the week. To extend the solution to do previous day, we just subtract the days rather than add them. To keep our code a bit more robust we'll use the date command to convert the requested day of the week to a number. Converting to a number normalizes our day so that "mon", "Mon", and "Monday" will all work. The format string option of the date command can once again can help us get the day as a number:

$ date --date 'Sunday' '+%u'
7
$ date --date 'Sunday' '+%w'
0

The %u format specifier outputs a number from 1 to 7 which corresponds to Monday thru Sunday. The %w format specifier outputs a number from 0 to 6 which corresponds to Sunday thru Saturday. The above date specifications may look a bit strange since there is no actual date, just the day of the week, in this case the date command just assumes you mean the closest date that falls on the day of the week that you specify.

So let's package up our idea as a function that accepts three arguments: the start date, the word "next" or "prev", and the requested day of the week. Obviously in the real world you would want to validate the passed arguments, but we'll skip that here:

function np_date()
{
    local date="$1"
    local dow=($(date --date "$3" '+%w')) # Convert day to number (0..6=Sun..Sat).

    if [[ $2 == 'next' ]]; then
        # Increment passed date till we get to the requested day.
        n=1
        while [[ $(date --date "$date +$n days" '+%w') != $dow ]]
        do
            let n++
        done
        date --date "$date +$n days" '+%Y-%m-%d'
    else
        # Decrement passed date till we get to the requested day.
        n=7
        while [[ $(date --date "$date -$n days" '+%w') != $dow ]]
        do
            let n--
        done
        date --date "$date -$n days" '+%Y-%m-%d'
    fi
}

The last date command after each loop produces the return value of the function by printing the date using the determined day offset. Note that I use the word return here to mean the value printed to stdout and not the exit status of the function.

Since this is a function, we need to source it (or . file it) in order to test it:

$ source np_date0.sh
$ np_date 2018-09-20 next Monday
2018-09-24
$ np_date 2018-09-20 next thu
2018-09-27
$ np_date 2018-09-20 next Fri
2018-09-21
$ np_date 2018-09-20 prev Tuesday
2018-09-18
$ np_date 2018-09-20 prev wed
2018-09-19
$ np_date 2018-09-20 prev Sat
2018-09-15

So that works pretty well, but I have to admit it feels a bit brute force to me invoking the date command continuously in a loop. Admittedly, it's at most seven times, but still it seems like we might be able to make this a bit "better".

So let's look at what we know: our start date can fall on one of seven possible days of the week and our requested day of the week can be on one of seven possible days. For each particular start day of the week, we can calculate how far off each requested day of the week is. For example, if the start date falls on a Sunday (0), the next Monday is one day ahead and the previous Monday is 6 days behind. If we calculate these values for each possible day of the week that the start date could be on, we have a two dimensional matrix for "next":

       Start
       S M T W T F S
Next
S      7 6 5 4 3 2 1
M      1 7 6 5 4 3 2
T      2 1 7 6 5 4 3
W      3 2 1 7 6 5 4
T      4 3 2 1 7 6 5
F      5 4 3 2 1 7 6
S      6 5 4 3 2 1 7

And one for "prev":

       Start
       S M T W T F S
Prev
S      7 1 2 3 4 5 6
M      6 7 1 2 3 4 5
T      5 6 7 1 2 3 4
W      4 5 6 7 1 2 3
T      3 4 5 6 7 1 2
F      2 3 4 5 6 7 1
S      1 2 3 4 5 6 7

The way to read these is to find the column that corresponds to the day of the week of the starting date and then read down that column to determine how many days to add or subtract from it to get to each possible next or previous day.

We can't use these directly since bash doesn't support multi-dimension arrays, but that's not really a problem here since we can use our requested day of the week to pick the row of the array that we're interested in.

So we package this up as a function which accepts two arguments: our next/prev word, and the requested day of the week. We then return the row corresponding to the requested day of the week from one of the two matrices above (depending on the next/prev word):

function day_offsets()
{
    local day_num=$(date --date "$2" '+%w')
    if [[ $1 == 'next' ]]; then
        local incr_d0=(7 6 5 4 3 2 1)  # Sun
        local incr_d1=(1 7 6 5 4 3 2)
        local incr_d2=(2 1 7 6 5 4 3)
        local incr_d3=(3 2 1 7 6 5 4)
        local incr_d4=(4 3 2 1 7 6 5)
        local incr_d5=(5 4 3 2 1 7 6)
        local incr_d6=(6 5 4 3 2 1 7)  # Sat
        eval echo \${incr_d${day_num}[*]}
    else
        local decr_d0=(7 1 2 3 4 5 6)  # Sun
        local decr_d1=(6 7 1 2 3 4 5)
        local decr_d2=(5 6 7 1 2 3 4)
        local decr_d3=(4 5 6 7 1 2 3)
        local decr_d4=(3 4 5 6 7 1 2)
        local decr_d5=(2 3 4 5 6 7 1)
        local decr_d6=(1 2 3 4 5 6 7)  # Sat
        eval echo \${decr_d${day_num}[*]}
    fi
}

To return the correct row in the "matrix" we could have done something like:

if [[ $day_num == 0 ]]; then
    echo 7 6 5 4 3 2 1
# ...
fi

Instead, we do it using an eval statement to generate the name of the correct row to return:

eval echo \${incr_d${day_num}[*]}

Assuming the requested day is Sunday (day number 0), after substitution of the day number we end up with:

echo ${incr_d0[*]}

Now we have our offsets to use for calculations, but we still need a new version of our np_date function to use them to calculate our next or previous date:

function np_date()
{
    local daynum=$(date --date "$1" '+%w')
    local offsets=($(day_offsets $2 $3))
    local -A operators=([next]=+ [prev]=-)

    date --date "$1 ${operators[$2]}${offsets[$daynum]} days" '+%Y-%m-%d'
}

The first line of the function converts the requested day to a number. The second line uses our day_offsets function to put our day offsets into an array. The third line creates an associative array with one key for each of our possible next/prev words. We use this to convert next/prev into the proper operator (+/-) to pass to the date command. The last line then passes all these parts to the date command to get the return date value. Just to make sure it still works:

$ source np_date1.sh
$ np_date 2018-09-20 next Monday
2018-09-24
$ np_date 2018-09-20 next thu
2018-09-27
$ np_date 2018-09-20 next Fri
2018-09-21
$ np_date 2018-09-20 prev Tuesday
2018-09-18
$ np_date 2018-09-20 prev wed
2018-09-19
$ np_date 2018-09-20 prev Sat
2018-09-15

I went through a few more versions of the day_offsets function in a continuing effort to try to make it better. The second version used a loop to generate the array, but I won't bore you with it since the result was more confusing than it was satisfying. The third and the fourth versions were based on the observation that if you take any row from one of the above matrices and double it, then you can pick out all the rest of the rows of that matrix from that result:

decr_d=(1 2 3 4 5 6 7 1 2 3 4 5 6 7)
incr_d=(7 6 5 4 3 2 1 7 6 5 4 3 2 1)

So now all we have to do is use the day of the week argument to pick out the correct starting point in the appropriate list above and then extract the next seven items. Version 3 used a loop to output the items, I'll skip that one too and just show you version 4, which removed the loop:

function day_offsets()
{
    local day_num=$(date -d "$2" "+%w")
    if [[ $1 == 'next' ]]; then
        local incr_d=(7 6 5 4 3 2 1 7 6 5 4 3 2 1)
        local start_offset=$((day_num == 0 ? day_num + 7 : 7 - day_num))
        echo ${incr_d[@]:$start_offset:7}
    else
        local decr_d=(1 2 3 4 5 6 7 1 2 3 4 5 6 7)
        local start_offset=$((day_num == 0 ? day_num + 6 : 6 - day_num))
        echo ${decr_d[@]:$start_offset:7}
    fi
}

Take particular note of the echo lines (e.g. echo ${xxx_incr[@]:$start_offset:7}). Instead of looping from the starting point, we take a "slice" of the array using the :start-expr:length-expr syntax (in our case :$start_offset:7) to get just the seven items that we want. Admittedly, this capability was something I only recently became aware of, I'd used this syntax to extract substrings from variables before, but never array slices. Don't feel bad if you missed it too, the problem is that it's not documented in the Arrays section of the man page, rather it's documented in the Parameter Expansion section where the :start-expr:length-expr syntax is described.

Let's test it one more time:

$ DAY_OFFSETS=3 source np_date1.sh
$ np_date 2018-09-20 next Monday
2018-09-24
$ np_date 2018-09-20 next thu
2018-09-27
$ np_date 2018-09-20 next Fri
2018-09-21
$ np_date 2018-09-20 prev Tuesday
2018-09-18
$ np_date 2018-09-20 prev wed
2018-09-19
$ np_date 2018-09-20 prev Sat
2018-09-15

If you're wondering what that DAY_OFFSETS=3 is all about, remember that there were four versions of the day_offsets function, in files named day_offsetsN.sh (N from 0 to 3). The day_offsets.sh script is actually just a short script that includes the appropriate version based on the value of the DAY_OFFSETS variable (picking version zero if the variable is not set):

$ cat day_offsets.sh
eval source day_offsets${DAY_OFFSETS:-0}.sh

We use the default value variable substitution syntax (${var:-default}) to get the value of DAY_OFFSETS and then use that with our old friend eval, rather than using an if statement, to source the corresponding file (again ignoring error checking).

So, did we succeed in making this better than our initial brute force version? We did use some interesting bash constructs in the attempt: eval, associative arrays, and array slicing, but after taking it all in, I'm not sure we made it all that much better.

Load Disqus comments