Calculating Timespans in Years, Months, Days in AutoHotkey, Part 2 (Understanding the HowLong() Function)

Taking a Close Look at the HowLong() Function for Calculating Years, Months, and Days

In this blog, I discuss in its entirety the most recent AutoHotkey code for the HowLongYearsMonthsDays.ahk script (introduced in my last blog). I’ve broken it up into snippets in order to explain the purpose of each piece. To get a complete copy of the script check out HowLongYearsMonthsDays.ahk at the “ComputorEdge Free AutoHotkey Scripts” page or for a barebones version (without comments and inactive code) see “Function Calculating Timespan in Years, Months, and Days” at the AutoHotkey Forum. This blog reviews the nuts and bolts of calculating the timespan between two dates.

The DateTime GUI Control

Calculate How LongThe script first sets up a GUI pop-up window with two DateTime controls—one for the start date and the other for the stop date. After the user selects the dates, AutoHotkey feeds them into the HowLong(StartDate,StopDate) function which returns the number of Years, Months, and Days of the timespan. The following code creates and opens the Calculate How Long GUI window:

Gui, SpanCalc:Add, Text, , Enter Start Date:
Gui, SpanCalc:Add, DateTime, vDate1, LongDate
Gui, SpanCalc:Add, Text, , Enter Stop Date:
Gui, SpanCalc:Add, DateTime, vDate2, LongDate
Gui, SpanCalc:Add, Button, , Calculate
Gui, SpanCalc:Show, , Calculate How Long

Note: The HowLongYearsMonthsDays.ahk script adds a mouse wheel scroll feature to the DateTime control. Normally, these settings only respond either to directly editing the numbers or pressing the up/down arrows to increment the values. To facilitate changing all values, I added mouse scroll wheel action using the #If directive to call the up/down arrow keys. (Code found at the end of the script.)

Calling the HowLong() Function

Clicking the Calculate button calls the built-in Label name SpanCalcButtonCalculate (composed of the GUI name, i.e. SpanCalc, the control type, i.e. Button, plus the button name, i.e. Calculate) subroutine:

  Gui, Submit, NoHide
  MsgBox, , TimeSpan Calculation
     , Years %Years%`rMonths %Months%`rDays %Days%

After executing the Gui, Submit command to save the current input to the vVariables (Date1 and Date2), AutoHotkey sends those values to the HowLong(Date1,Date2) function as parameters. The HowLong() function calculates the global variables Years, Months, and Days. The function takes the form:

Global Years,Months,Days

[Remainder of the code]


TimespanVariables inside a function default to Local status. That means they only affect code inside the function—invisible outside the function. To make the variables universally available in the script as well as the function, you must either declare the variables (Years, Months, Days) Global within the function or in the main script—making the variables available to the entire script and all functions.

Conforming the Function Parameters

If the DateTime stamps don’t match in length, they can return inconsistent results when compared to each other. To prevent these types of errors, trim the time components off the input variables:

FromDay := SubStr(FromDay,1,8)
ToDay := SubStr(ToDay,1,8)

Check for Proper Date Order

You’ll find it easy to accidentally input a start date later than the stop date. Calculating in that direction (HowLongAgo()?) offers a different problem. To trap this error, we add the following code:

If (ToDay <= FromDay)
  Years := 0, Months := 0, Days := 0
  MsgBox, Start date after end date!

Whenever the target date is less than or equal to the beginning date, AutoHotkey sets all values (Years, Months, Days) to 0, then displays the error message, “Start date after end date!” and exits the function with the Return command.

Determining Leap Years

Library BenefitsInitially, I used the following routine to detect leap years by evaluating the existence of February 29th in the target year:

Feb29 := SubStr(ToDay,1,4) . "0229"
Feb29 += 1, days ; Test for Leap Year

If (Feb29 != "")
  Feb := 29
  Feb := 28

An invalid date calculation for any year not containing a February 29th (Feb29 += 1, days) returns blank. If not blank, it’s a leap year.

However, I switched to another calculating technique for determining the length of any given month (posted on the AutoHotkey Forum):

Date1 := 20200201
Date2 := 20200301
Date2 -= Date1, Days  ; Uses the EnvSub command
MsgBox, % Date2       ; Returns 29

Now, since AutoHotkey directly calculates the length of the previous month in the target year, the function no longer requires the original leap year test. I’ve left the original code (which I commented-out and does not run) intact in the script for people who may want an alternative approach to identifying a leap year.

Calculating Years

Possibly the simplest calculations to grasp in this function, when the target month occurs after the start month in any given year, merely subtract the start year from the stop year. Otherwise, subtract one from that difference to account for the partial year. I used the ternary operator to code this conditional calculation in one line:

Years := % SubStr(ToDay,5,4) - SubStr(FromDay,5,4) < 0 
   ? SubStr(ToDay,1,4)-SubStr(FromDay,1,4)-1
   : SubStr(ToDay,1,4)-SubStr(FromDay,1,4)

(I word-wrapped this one line of code, and many of the other long code lines, using AutoHotkey line continuation techniques for display purposes in this blog. AutoHotkey reads each wrapped line as one continuous line.)

Remove years from the calculation by increasing the start date by the number of whole years found in the difference. This sets the Years value and eliminates it from further calculations:

FromYears := Substr(FromDay,1,4)+years . SubStr(FromDay,5,4)

Notice that DateTime timestamps require string concatenation to properly form the variable.

Calculating Months

Calculate the number of months between the new start date value (FromDay with whole years removed. i.e. FromYears) and the stop date (ToDay).

If the month number in the adjusted start date is less than the month number in the stop date, then either use the difference in the month numbers or use that number minus 1—depending upon the comparison between the two month days. If the start day of the month is less than the stop day of the month use the same month. Otherwise, use the previous month.

If the month number in the adjusted start date is greater than the month number in the stop date, then either add 11 (one month prior to the stop month) or 12 months to the calculation—depending upon the comparison between the two month days. If the start day of the month is less than the stop day of the month use the same month. Otherwise, use the previous month:

If (Substr(FromYears,5,2) <= Substr(ToDay,5,2)) 
   and (Substr(FromYears,7,2) <= Substr(ToDay,7,2))
      Months := Substr(ToDay,5,2) - Substr(FromYears,5,2)
Else If (Substr(FromYears,5,2) < Substr(ToDay,5,2))
   and (Substr(FromYears,7,2) > Substr(ToDay,7,2))
      Months := Substr(ToDay,5,2) - Substr(FromYears,5,2) - 1
Else If (Substr(FromYears,5,2) > Substr(ToDay,5,2)) 
   and (Substr(FromYears,7,2) <= Substr(ToDay,7,2))
      Months := Substr(ToDay,5,2) - Substr(FromYears,5,2) +12
Else If (Substr(FromYears,5,2) >= Substr(ToDay,5,2)) 
   and (Substr(FromYears,7,2) > Substr(ToDay,7,2))
      Months := Substr(ToDay,5,2) - Substr(FromYears,5,2) +11

You may need to think about this one. You might want to take out a calendar and test a number of variations to digest what these conditionals do.

Ultimately, we remove the months from the calculation (FromMonth) by adjusting the month forward to either the stop month or one month prior to the stop month:

If (Substr(FromYears,7,2) <= Substr(ToDay,7,2))
    FromMonth := Substr(ToDay,1,4) 
      . SubStr(ToDay,5,2) . Substr(FromDay,7,2)
Else If Substr(ToDay,5,2) = "01"
    FromMonth := Substr(ToDay,1,4)-1 
      . "12" . Substr(FromDay,7,2)
    FromMonth := Substr(ToDay,1,4) 
    . Format("{:02}", SubStr(ToDay,5,2)-1) . Substr(FromDay,7,2)

We must account for the transition between January (01) back to December (12) (shown in green) when the start month day number is greater than the stop month day number.

One formatting problem occurs in the recreation of the DateTime stamp when the calculation returns a single digit for the earlier months. For the function to work, we must correct the date variable by padding those single digit months with a “0” as the first digit (i.e. 01, 02, 03, …). Initially, I used the following trick to change the format:

Substr("0" . SubStr(ToDay,5,2)-1,-1)

However, I changed it to the Format() function in the latest version (as shown above in red). Both approaches produce valid DateTime stamps, but the Format() function offers consistency without any sleight of hand.

Note: The Format() function deserves a little more attention to develop a proper understanding of how it works and its practical application. Definitely a topic worth pursuing in the future.

Months with Less Than 31 Days

If AutoHotkey attempts a date calculation with a nonexistent day of the month (e.g. September 31), it returns a null value. That means we must adjust any FromMonth containing a non-existent date to the appropriate lower month length.

Originally, I included a set of conditionals which adjusted all the short months (including February in a leap year after the test mentioned toward the beginning of this blog):

If (Substr(FromMonth,5,2) = "02") and (Substr(FromDay,7,2) > "28")
    FromMonth := Substr(FromMonth,1,6) . Feb
If (Substr(FromMonth,5,2) = "04") and (Substr(FromDay,7,2) > "30")
    FromMonth := Substr(FromMonth,1,6) . "30"
If (Substr(FromMonth,5,2) = "06") and (Substr(FromDay,7,2) > "30")
    FromMonth := Substr(FromMonth,1,6) . "30"
If (Substr(FromMonth,5,2) = "09") and (Substr(FromDay,7,2) > "30")
    FromMonth := Substr(FromMonth,1,6) . "30"
If (Substr(FromMonth,5,2) = "11") and (Substr(FromDay,7,2) > "30")
    FromMonth := Substr(FromMonth,1,6) . "30"

This worked fine but after I noted that I could easily calculate the length of the previous month, I replaced the month length adjustment conditionals above with the following:

Date1 := Substr(FromMonth,1,6) . "01"
Date2 := Substr(ToDay,1,6) . "01"
Date2 -= Date1, Days
If (Date2 < Substr(FromDay,7,2)) and (Date2 != 0) 
    FromMonth := Substr(FromMonth,1,6) . Date2

Since in any leap year the snippet above calculates February as 29 days, this code also eliminates the need for the leap year test found early in this function.

Calculate Remaining Days

We calculate the remaining days using the EnvSub command. Peculiarly, with time calculations, this operation (EnvSub) alters the type of the original variable from a DateTime stamp into numeric time units (days, hours, minutes, or seconds). If we need to keep the original variable type and value unchanged, then we must create a new variable set to its original value. However, after this final day-calculating step, I no longer need the ToDay variable in its original form. The function has completed all calculations:

ToDay -= %FromMonth% , d
Days := ToDay

If you prefer a cleaner form of the routine, set Days to ToDay before the calculation:

Days := ToDay
Days -= %FromMonth% , d  ; Returns the number of days

AutoHotkey Version 2.0 Note: The coming V2.0 resolves this EnvSub/EnvAdd variable altering oddity by replacing time calculations with the DateDiff() and DateAdd() functions—both of which work in a standard manner:

Result := DateDiff(DateTime1, DateTime2, TimeUnits)

DateDiff() returns time units while DateAdd() returns a DateTime stamp without altering any other variables.

That completes the HowLong(StartDate,StopDate) function. The global variables Years, Months, and Days contain the timespan from the start date to the stop date.

Enable the Mouse Wheel for Scrolling Incremental Controls in AutoHotkey GUIs

When I first set up the DateTime GUI control, I was annoyed at needing to use the up/down cursor keys to increment the control values. By default, the mouse scroll wheel does not work.

Noting that I control the volume level of my speakers by hovering the mouse cursor over the Windows Taskbar while scrolling the mouse wheel (ChangeVolume.ahk), I added a variation of that capability to this script and it works like a charm:

#If MouseIsOver("ahk_class AutoHotkeyGUI")
  WheelUp::Send {Up}
  WheelDown::Send {Down}

  MouseGetPos,,, Win
  Return WinExist(WinTitle . " ahk_id " . Win)

I discuss how the ChangeVolume.ahk script works in Chapter Six of the book AutoHotkey Hotkey Techniques.


This post was proofread by Grammarly
(Any other mistakes are all mine.)

(Full disclosure: If you sign up for a free Grammarly account, I get 20¢. I use the spelling/grammar checking service all the time, but, then again, I write a lot more than most people. I recommend Grammarly because it works and it’s free.)

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s