Work the Shell

Days Between Dates: the Counting

Dave Taylor

Issue #244, August 2014

Dave continues developing a script to calculate how many days ago a specific date was—and finds that there are bugs!

In my last article, we began an exploration of date math by validating a given date specified by the user, then explored how GNU date offers some slick math capabilities, but has some inherent limitations, the most notable of which is that it isn't on 100% of all Linux and UNIX systems.

So here, let's continue the development by adding the date math itself. For this script, the goal is to answer the question of how many days have transpired between the specified date and the current date. The necessary algorithm has obvious applications in the broader “date A to date B” puzzle, but let's defer that for another time.

Remember also that we're talking about dates, not time, so it's not counting 24-hour increments but simply the number of days. So running the completed script at 11:59pm will offer a different result from at 12:01am, just a few seconds later.

To start, let's grab the current month, day and year. There's a neat eval trick we can use to do this:

eval $(date "+thismon=%m;thisday=%d;thisyear=%Y;dayofyear=%j")

This instantiates thismon to the current month, thisday to the current day of the month, thisyear to the current year and dayofyear to the numeric day number into the year of the current day, all in a single line—neat.

The user-specified starting date already is broken down into day of month, month of year and year, so once the current month, day and year are identified, there are four parts to the equation:

  1. How many years have transpired * 365.

  2. How many days were left in the starting year.

  3. How many days have transpired since the beginning of this year.

  4. Compensate for leap years in the interim period.

The first test demonstrates the nuances in this calculation because if we're calculating the number of days since, say, Nov 15, 1996, and today, Jun 3, 2014, we don't want to count 365*(2014–1996) because both the start and end years will be wrong. In fact, better math is to use this formula:

365 * (thisyear – starting year – 2)

But, that's not right either. What happens if the starting date is Jun 1, 2014, and the end date is Jun 3, 2014? That should then produce a zero value, as it also would if the start date was Mar 1, 2013, even though 2014–2013=1. Here's my first stab at this chunk of code:

if [ $(( $thisyear - $startyear )) -gt 2 ] ; then
  elapsed=$(( $thisyear - $startyear - 2 ))
  basedays=$(( elapsed * 365 ))
else
  basedays=0
fi
echo "$basedays days transpired between end of $startyear \
     and beginning of this year"

Now that isn't taking into account leap years, is it? So instead, let's try doing it differently to tap into the isleap function too:

if [ $(( $thisyear - $startyear )) -gt 2 ] ; then
  # loop from year to year, counting years and adding +1 
  # for leaps as needed
  theyear=$startyear
  while [ $theyear -ne $thisyear ] ; do
    isleap $theyear
    if [ -n "$leapyear" ] ; then
      elapsed=$(( $elapsed + 1 ))
      echo "(adding 1 day to account for $theyear being a leap)"
    fi
    elapsed=$(( $elapsed + 365 ))
    theyear=$(( $theyear + 1 ))
  done
fi

Fast and easy. When I run this block against 1993 as a starting year, it informs me:

(adding 1 day to account for 1996 being a leap year)
(adding 1 day to account for 2000 being a leap year)
(adding 1 day to account for 2004 being a leap year)
(adding 1 day to account for 2008 being a leap year)
(adding 1 day to account for 2012 being a leap year)
7670 days transpired between end of 1993 and beginning of this year

For the second step in the algorithm, calculating the number of days from the specified starting date to the end of that particular year, well, that too has an edge case of it being the current year. If not, it's a calculation that can be done by summing up the days of each of the previous months plus the number of days into the month of the starting date, then subtracting that from the total days in that particular year (since we have to factor in leap years, which means we have to consider whether the date occurs before or after Feb 29), or we can do the same basic equation, but sum up the days after the specified date. In the latter case, we can be smart about the leap day by tracking whether February is included.

The basic code assumes we have an array of days-per-month for each of the 12 months or some other way to calculate that value. In fact, the original script included this snippet:

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

A logical approach would be to turn this into a short function that can do double duty. That's easily done by wrapping it in function daysInMonth { and }.

With that, it's a matter of stepping through the remaining months, although be alert for the leap year calculation we need to do if month = 2 (Feb). The program has February always having 29 days, so if it isn't a leap year, we need to subtract one day to compensate:

if [ $thisyear -ne $startyear ] ; then
  monthsleft=$(( $startmon + 1 )) 
  daysleftinyear=0
  while [ $monthsleft -le 12 ] ; do
    if [ $monthsleft -eq 2 ] ; then  # February. leapyear?
      isleap $startyear
      if [ -n "$leapyear" ] ; then
        daysleftinyear=$(( $daysleftinyear + 1 )) # feb 29!
      fi
    fi
    daysInMonth $monthsleft
    daysleftinyear=$(( $daysleftinyear + $dim ))
    monthsleft=$(( $monthsleft + 1 ))
  done
else
  daysleftinyear=0    # same year so no calculation needed
fi

The last part is to calculate how many days are left in the starting date's month, again worrying about those pesky leap years. This is only necessary if the start year is different from the current year and the start month is different from the current month. In the case that we're in the same year, as you'll see, we can use “day of year” and calculate things differently.

Here's the block of code:

if [ $startyear -ne $thisyear -a $startmon -ne $thismon ] ; then
  daysInMonth $startmon
  if [ $startmon -eq 2 ] ; then   # edge case: February
    isleap $startyear
    if [ -z "$leapyear" ] ; then
      dim=$(( $dim - 1 ))  # dim = days in month
    fi
  fi
  daysleftinmon=$(( $dim - $startday ))
  echo "calculated $daysleftinmon days left in the startmon"
fi

We have a few useful variables that now need to be added to the “elapsed” variable: daysleftinyear is how many days were left in the start year, and dayofyear is the current day number in the current year (June 3, for example, is day 154). For clarity, I add it like this:

echo calculated $daysleftinyear days left in the specified year
elapsed=$(( $elapsed + daysleftinyear ))
# and, finally, the number of days into the current year
elapsed=$(( $elapsed + $dayofyear ))
echo "Calculated that $startmon/$startday/$startyear \
   was $elapsed days ago."

With that, let's test the script with a few different inputs:

$ sh daysago.sh 8 3 1980
The date you specified -- 8-3-1980 -- is valid. Continuing...
12419 days transpired between end of 1980 and beginning of this year
calculated 28 days left in the startmon
calculated 122 days left in the specified year
Calculated that 8/3/1980 was 12695 days ago.
$ sh daysago.sh 6 3 2004
The date you specified -- 6-3-2004 -- is valid. Continuing...
3653 days transpired between end of 2004 and beginning of this year
calculated 184 days left in the specified year
Calculated that 6/3/2004 was 3991 days ago.

Hmm...365*10 = 3650. Add a few days for the leap year, and that seems wrong, doesn't it? Like it's one year too many or something? Worse, look what happens if I go back exactly two years ago:

$ sh daysago.sh 6 3 2012
The date you specified -- 6-3-2012 -- is valid. Continuing...
0 days transpired between end of 2012 and beginning of this year
calculated 184 days left in the specified year
Calculated that 6/3/2012 was 338 days ago.

Something is definitely wrong. That should be 2*365. But it's not. Bah. Phooey. In my next article, we'll dig in and try to figure out what's wrong!

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.