Work the Shell

Days Between Dates?

Dave Taylor

Issue #243, July 2014

Date math—it's not so easy, but Dave jumps in to create the utility DaysAgo.sh.

Alert readers will know that I'm working on a major revision to my popular Wicked Cool Shell Scripts book to come out later this year. Although most of the scripts in this now ten-year-old book still are current and valuable, a few definitely are obsolete or have been supplanted by new technology or utilities. No worries—that's why I'm doing the update.

One script I'll be adding is a complicated one that I'm going to develop here in my Linux Journal column: daysago. The script will take a specified date in the past and tell you how many days have elapsed between that date and the current day and time.

You might be thinking that's fairly complicated, and it is, but not in the way you might be thinking. The actual calculation is really easy because of how Linux systems store and manipulate dates. The challenge is in parsing the input.

The first part of the book includes a library of useful scripting utilities, however, and one just so happens to be what we want—no coincidence that!

Valid Date?

The easiest way to deal with something as complicated as a date is to force the work onto the user. There are a couple different strategies for that, but let's be lazy for now and prompt the user for the month, then day, then year, requiring numeric values. Then, we'll need to check whether it's valid.

Validating a user-specified date is pretty straightforward until we get to the issue of leap years. We're used to thinking that every four years is a leap year, but the formula is quite a bit more complicated than that, and it can be summarized with this set of rules:

  • Years divisible by four are leap years, unless...

  • The year also is divisible by 100, except if...

  • The year is divisible by 400, in which case it is.

Is that complicated enough? Of course, if we're just looking at leap years in the last few decades, it's not a very big deal, but it's inevitable that someone will try something like Feb 29, 1776, in which case we need to know whether it's valid.

Or, we can be lazy.

Since I like the lazy solution to things (remember, I'm not writing production code here, I'm demonstrating concepts), let's cheat by using the Linux cal command. Because it lets us specify month/year, we can hand off the question of whether there's a February 29 in the year 1776 by just asking for a display of 2/1776:

$ cal 2 1776
   February 1776
Su Mo Tu We Th Fr Sa
             1  2  3
 4  5  6  7  8  9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29

It looks like 1776 was indeed a leap year. No wonder they had time to draft the Constitution before summer came along and made Philly too darn hot for anyone to work!

To turn this command into a script, a simple grep and test for nonzero results does the trick:

mon=$1; day=$2; year=$3

if [ $mon -eq 2 -a $day -eq 29 ] ; then
  echo checking for feb 29 : was $3 a leap year?
  leapyear=$(cal 2 $year | grep '29')
  if [ ! -z "$leapyear" ] ; then
    echo "Yes, $year was a leapyear, so February 29, $year \
    is a valid date."
  else
    echo "Oops, $year wasn't a leapyear, so February only \
    had 28 days."
  fi
fi

Let's run a few quick tests to see what happens:

$ sh valid-date.sh 2 29 1777
checking for feb 29 : was 1777 a leap year?
Oops, 1777 wasn't a leapyear, so February only had 28 days.
$ sh valid-date.sh 2 29 1776
checking for feb 29 : was 1776 a leap year?
Yes, 1776 was a leapyear, so February 29, 1776 is a valid date.

That makes sense, and it's sure easy to use cal for this particular test.

We still need to encapsulate the “30 days have September, April, June and November” information too, and that's easily done with a rather compact case statement:

case $mon in
  1|3|5|7|8|10|12 ) dim=31 ;; # most common value
  4|6|9|11        ) dim=30 ;;
  2               ) dim=29 ;;  # possible leap year?
  *               ) dim=-1 ;;  # unknown month
esac

In this case, the variable we're setting is “days in month” or dim (not a reference to A Clockwork Orange, my cineophile readers). This makes it easy to check all but Feb 29 as a possible date, as demonstrated in this simple conditional:

if [ $day -lt 0 -o $day -gt $dim ] ; then
  echo "Invalid date: Month #$mon has $dim days, so day \
  $day is impossible."
fi

There are a bunch of different ways to do this, of course, but because most months have 31 days, again, I'm looking for the shortcut!

Mixed together and slightly tweaking the output, we now can test the validity of any date specified in the correct month, day, year format:

$ sh valid-date.sh 2 29 2013
The date you specified -- 2-29-2013 -- is valid. Continuing...
$ sh valid-date.sh 1 33 2013
Invalid date: Month #1 has 31 days, so day 33 is impossible.
$ sh valid-date.sh 2 29 2013
2013 wasn't a leapyear, so February only had 28 days.

Ahh, that all works just fine.

We started out by deciding that all the date formatting issues were going to be pushed to the user, but we still need to do some rudimentary tests, at least this one:

if [ $# -ne 3 ] ; then
  echo "Usage: $(basename $0) mon day year"
  echo "  with just numerical values (ex: 7 7 1776)"
  exit 1
fi

Yes, this month the script isn't glamorous—such is the life of a scripter.

With a valid date, there's a tendency to use something like GNU date to do the math (see the GNU date sidebar), but that has some inherent limitations, not the least of which is that it won't work with any dates prior to 1970.

I'll stop here for this month, but next month, we'll take the date we've validated and see if there's a formula to count the number of days quickly from then to now!

Dave Taylor has been hacking shell scripts for more than 30 years. Really. He's the author of the popular Wicked Cool Shell Scripts and can be found on Twitter as @DaveTaylor and more generally at his tech site www.AskDaveTaylor.com.