5
\$\begingroup\$

I've been working on this one for a while. Took me a while to figure out all the logic rules for dates and months.

The goal of this method is to return the number of years, months, days, hours, minutes, seconds, and milliseconds between two times.

I'm going to be implementing this algorithm in a Xamarin app I'm making that shows an active countdown, but that's neither here nor there in terms of getting this coded properly.

A few "business rules" I wanted to try to abide by.

  1. When two dates are an exact amount of time apart, express that in the return object. IE, 1/31/2019 to 1/31/2020 is 1 year. Not 11 months, 30 days. Likewise 3/15/2019 to 4/15/2019 is 1 month, not 31 days
  2. As this will be used in a countdown program, there should not be any "lost days" in the countdown.

    For example, when going from 02/27/2019 to 04/07/2019, the result should be counted as 1 month, 8 days (counting the days first), and not 1 month 11 days (counting the month first).

    The reason for this is that as time advances, going from 2/28/2019 to 03/01/2019 would result in an output of 1 month 10 days for the first date. But on the second date, it would result in 1 month 6 days which wouldn't make sense as you are losing a few days in the middle there.

  3. The return object is not bound by any calendar "business rules". Meaning a return object of 2 months and 30 days is valid. However, the number of days should never meet or exceed the number of days in the calendar month immediately following the start date. This is a product of the counting down of days to zero described above.

CODE

/// <summary>
/// Calculates the difference in years, months, days, hours, seconds, and milliseconds between a start date and time and end date and time.
/// </summary>
/// <param name="startDate">The start date and time</param>
/// <param name="endDate">The end date and time</param>
/// <returns>A TimeRemaining object which represents the difference between the two times in years, months, days, hours, seconds, and milliseconds</returns>
/// <remarks>If startDate is greater than or equal to end date, returns 0 for all TimeRemaining fields</remarks>
public static TimeDifference SubtractDates(DateTime startDate, DateTime endDate)
{
    if (startDate <= endDate)
    {
        //identify median months between two dates
        List<DateTime> medianMonths = new List<DateTime>();

        //start with the first calendar month after the start date
        int monthsIterator = 1;
        DateTime iterativeMonth = startDate.AddMonths(monthsIterator);

        //total full months (we are going to return this)
        int months = 0;

        //continue counting months until you reach or surpass the end date
        while (iterativeMonth < endDate)
        {
            months++;
            monthsIterator++;

            //we use the iterator applied against the start month 
            //to account for edge cases like a start date of 1/31/2019 and a 
            //deadline of 3/31/2019.
            //
            //when adding "1 month" to 1/31/2019, c# will return 2/28/2019, so when you 
            //iterate the next month after, it will be 3/28/2019 instead of 3/31/2019.
            iterativeMonth = startDate.AddMonths(monthsIterator);
        }


        //construct false "end date" with the same day of the month in the month immediately following the start date
        //get year and month of faux date
        int fauxIterator = 0;

        //this accounts for "lost month" when you have start days that are numerically larger than end days
        //example start: 02/27/2019; end: 04/07/2019
        if (startDate.Day > endDate.Day)
        {
            fauxIterator++;
        }

        // get the faux year and month.
        int fauxYear = startDate.AddMonths(fauxIterator).Year;
        int fauxMonth = startDate.AddMonths(fauxIterator).Month;

        //get the days in the faux month
        int fauxDaysInMonth = DateTime.DaysInMonth(fauxYear, fauxMonth);

        //for endDates that have a day value numerically larger than the number of days in a given month,
        //perform a "bonus" month to account for the month that is completely traversed
        //
        //example start: 02/27/2019; end: 04/07/2019
        bool bonus = false;
        if (endDate.Day > fauxDaysInMonth)
        {
            fauxIterator++;
            fauxYear = startDate.AddMonths(fauxIterator).Year;
            fauxMonth = startDate.AddMonths(fauxIterator).Month;
            bonus = true;
        }

        //reset faux days in month after first test
        fauxDaysInMonth = DateTime.DaysInMonth(fauxYear, fauxMonth);

        //get the faux date and total days between the start date
        //and the faux date
        //
        //this will include the traversed month if applicable
        DateTime faux = new DateTime(fauxYear, fauxMonth, endDate.Day, endDate.Hour, endDate.Minute, endDate.Second, endDate.Millisecond);

        //track the original faux day to handle 'lost days' if faux date is less than start date
        int originalFauxDay = faux.Day;

        //if faux is less than start date, advance the month to correct the days
        //this will only happen when fauxIterator == 0, but testing for 
        //that introduces other problems for legitimate close dates
        if (faux < startDate)
        {
            fauxIterator++;
            fauxYear = startDate.AddMonths(fauxIterator).Year;
            fauxMonth = startDate.AddMonths(fauxIterator).Month;
            //add the month to get the faux day as well. This is 
            //important if working with an edge case like 01/31
            int fauxDay = startDate.AddMonths(fauxIterator).Day;
            faux = new DateTime(fauxYear, fauxMonth, fauxDay, endDate.Hour, endDate.Minute, endDate.Second, endDate.Millisecond);
        }

        //if days were lost in the immediately preceeding if clause
        //correct for the lost days here by finding the number of lost days
        //
        //solves test case of
        //start: 1/31/2019 1200
        //end:   3/31/2019 0000
        int fauxCorrectionOffset = 0;
        if(originalFauxDay > faux.Day)
        {
            fauxCorrectionOffset = originalFauxDay - faux.Day;
        }

        int days = 0;
        days = (faux - startDate).Days + fauxCorrectionOffset;

        //order is important, so if a traversed month was included,
        //remove the traversed month days first
        if (bonus)
        {
            //since the faux date is currently on the second month (first after the traversed one)
            //skip back to the traversed month to remove its days
            faux = faux.AddMonths(-1);
            fauxDaysInMonth = DateTime.DaysInMonth(faux.Year, faux.Month);
            days = days - fauxDaysInMonth;
        }

        //prepare to continue calculations with the original faux month
        //these two lines will only affect the outcome if a bonus (traversed) month was 
        //included in the previous calculations
        faux = faux.AddMonths(1);
        fauxDaysInMonth = DateTime.DaysInMonth(faux.Year, faux.Month);

        //if endDate day is numerically higher than the number of days in the second month,
        //and the days remaining are more than are in the second faux month
        //remove the days of the second month
        if (endDate.Day >= fauxDaysInMonth && bonus && days >= fauxDaysInMonth)
        {
            days = days - fauxDaysInMonth;
        }

        //handles edge cases where a startDate and endDate are 
        //an exact number of months or years apart
        //
        //Ticks is also a part of the criteria so that when the dates are the same, 
        //months is not falsely iterated.
        if ((days == 0) &&
            ((endDate - startDate).Hours == 0) &&
            ((endDate - startDate).Minutes == 0) &&
            ((endDate - startDate).Seconds == 0) &&
            ((endDate - startDate).Milliseconds == 0) &&
            ((endDate - startDate).Ticks > 0))
        {
            months++;
        }

        //integer division to get number of whole years
        int years = months / 12;

        //mod months to get number of total months after years
        months = months % 12;

        //use timespan against original dates to 
        //compute hours, minutes, seconds, and milliseconds
        TimeSpan pureDiff = endDate - startDate;

        //save days in 'countdown month'
        //save the days in month. This is what the output is counting down from
        int daysInCountdownMonth = 0;
        if (startDate.Day >= endDate.Day &&
            !(days == 0))
        {
            daysInCountdownMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month);
        }
        else
        {
            daysInCountdownMonth = DateTime.DaysInMonth(startDate.AddMonths(-1).Year, startDate.AddMonths(-1).Month);
            if(daysInCountdownMonth < endDate.Day)
            {
                //correct with offset
                //this should account for situations where the end date day is numerically larger
                //than the number of days in the preceeding month
                int offset = endDate.Day - daysInCountdownMonth;
                daysInCountdownMonth = daysInCountdownMonth + offset;
            }
        }

        return new TimeDifference(years, months, days, pureDiff.Hours, pureDiff.Minutes, pureDiff.Seconds, pureDiff.Milliseconds, daysInCountdownMonth);
    }
    else
    {
        return new TimeDifference(0, 0, 0, 0, 0, 0, 0, 0);
    }
}

By request, here is the code for the TimeRemaining class. Very straightforward, but not nearly as well documented.

/// <summary>
/// Class that represents the difference between two times
/// </summary>
public class TimeDifference : IEquatable<TimeDifference>
{
    private int _days;
    private int _hours;
    private int _millliseconds;
    private int _minutes;
    private int _months;
    private int _seconds;
    private int _years;
    private int _daysInMonth;

    private const int DAYS_IN_WEEK = 7;

    public TimeDifference()
    {
        this.Years = 0;
        this.Months = 0;
        this.Days = 0;
        this.Hours = 0;
        this.Minutes = 0;
        this.Seconds = 0;
        this.Milliseconds = 0;
        this.DaysInMonth = 0;
    }

    public TimeDifference(int years, int months, int days, int hours, int minutes, int seconds, int milliseconds, int daysInMonth)
    {
        this.Years = years;
        this.Months = months;
        this.Days = days;
        this.Hours = hours;
        this.Minutes = minutes;
        this.Seconds = seconds;
        this.Milliseconds = milliseconds;
        this.DaysInMonth = daysInMonth;
    }

    public static bool operator ==(TimeDifference left, TimeDifference right)
    {
        bool status = false;
        if (left.Years == right.Years &&
            left.Months == right.Months &&
            left.Days == right.Days &&
            left.Hours == right.Hours &&
            left.Minutes == right.Minutes &&
            left.Seconds == right.Seconds &&
            left.Milliseconds == right.Milliseconds)
        {
            status = true;
        }
        return status;
    }

    public static bool operator !=(TimeDifference left, TimeDifference right)
    {
        bool status = false;
        if (left.Years != right.Years ||
            left.Months != right.Months ||
            left.Days != right.Days ||
            left.Hours != right.Hours ||
            left.Minutes != right.Minutes ||
            left.Seconds != right.Seconds ||
            left.Milliseconds != right.Milliseconds)
        {
            status = true;
        }
        return status;
    }

    public bool Equals(TimeDifference other)
    {
        if (ReferenceEquals(null, other))
        {
            return false;
        }
        if (ReferenceEquals(this, other))
        {
            return true;
        }

        return this.Years.Equals(other.Years)
            && this.Months.Equals(other.Months)
            && this.Days.Equals(other.Days)
            && this.Hours.Equals(other.Hours)
            && this.Minutes.Equals(other.Minutes)
            && this.Seconds.Equals(other.Seconds)
            && this.Milliseconds.Equals(other.Milliseconds);
    }

    public override bool Equals(object obj)
    {
        if (ReferenceEquals(null, obj))
        {
            return false;
        }
        if (ReferenceEquals(this, obj))
        {
            return true;
        }

        return obj.GetType() == GetType() && Equals((TimeDifference)obj);
    }

    public override int GetHashCode()
    {
        unchecked
        {
            int hashCode = Years.GetHashCode();
            hashCode = (hashCode * 397) ^ Months.GetHashCode();
            hashCode = (hashCode * 397) ^ Days.GetHashCode();
            hashCode = (hashCode * 397) ^ Hours.GetHashCode();
            hashCode = (hashCode * 397) ^ Minutes.GetHashCode();
            hashCode = (hashCode * 397) ^ Seconds.GetHashCode();
            hashCode = (hashCode * 397) ^ Milliseconds.GetHashCode();
            hashCode = (hashCode * 397) ^ DaysInMonth.GetHashCode();
            return hashCode;
        }
    }

    public override string ToString()
    {
        return Years + "y " + Months + "m " + Days + "d " + Hours + "h " + Minutes + "min " + Seconds + "s " + Milliseconds.ToString("000") + "ms";
    }

    public int Hours
    {
        get
        {
            return this._hours;
        }
        set
        {
            this._hours = value;
        }
    }

    public int Milliseconds
    {
        get
        {
            return this._millliseconds;
        }
        set
        {
            this._millliseconds = value;
        }
    }

    public int Minutes
    {
        get
        {
            return this._minutes;
        }
        set
        {
            this._minutes = value;
        }
    }

    public int Months
    {
        get
        {
            return this._months;
        }
        set
        {
            this._months = value;
        }
    }

    public int Days
    {
        get
        {
            return this._days;
        }
        set
        {
            this._days = value;
        }
    }

    public int DaysInMonth
    {
        get
        {
            return this._daysInMonth;
        }
        set
        {
            this._daysInMonth = value;
        }
    }

    public int Weeks
    {
        get
        {
            return Days / DAYS_IN_WEEK;
        }
    }

    public int DaysRemainder
    {
        get
        {
            return Days % DAYS_IN_WEEK;
        }
    }

    public int Seconds
    {
        get
        {
            return this._seconds;
        }
        set
        {
            this._seconds = value;
        }
    }

    public int Years
    {
        get
        {
            return this._years;
        }
        set
        {
            this._years = value;
        }
    }
}

Tests and Edge Cases

I have also written unit tests which verify the code is working in the following scenarios. If you can think of any edge cases that will break the algorithm, please post them!

  • Start Date: 02/27/2019
    End Date: 04/07/2019
    Result: 1 month, 8 days
  • Start Date: 02/27/2020 (accounts for leap year)
    End Date: 04/07/2020
    Result: 1 month, 9 days
  • Start Date: 03/31/2019
    End Date: 03/31/2019
    Result: 0 for all
  • Start Date: 03/31/2020
    End Date: 03/31/2020
    Result: 0 for all
  • Start Date: 01/31/2019
    End Date: 03/31/2020
    Result: 1 year, 2 months
  • Start Date: 02/1/2019
    End Date: 03/31/2020
    Result: 1 year, 1 month, 30 days
  • Start Date: 02/2/2019
    End Date: 03/31/2020
    Result: 1 year, 1 month, 29 days
  • Start Date: 02/3/2019
    End Date: 03/31/2020
    Result: 1 year, 1 month, 28 days
  • Start Date: 02/4/2019
    End Date: 03/31/2020
    Result: 1 year, 1 month, 27 days
  • Start Date: 03/31/2019
    End Date: 03/31/2020
    Result: 1 year
  • Start Date: 04/1/2019
    End Date: 03/31/2020
    Result: 11 months, 30 days
  • Start Date: 02/28/2020
    End Date: 02/28/2020
    Result: 0 for all
  • Start Date: 02/29/2020
    End Date: 02/29/2020
    Result: 0 for all
  • Start Date: 03/15/2019
    End Date: 04/15/2019
    Result: 1 month
  • Start Date & Time: 10/19/2019 11:00 am
    End Date & Time: 10/20/2019 11:00 am
    Result: 1 day
  • Start Date & Time: 2/19/2019 11:00 am
    End Date & Time: 10/20/2019 11:00 am
    Result: 8 months, 1 day
  • Start Date & Time: 2/19/2019 10:00 am
    End Date & Time: 10/20/2019 11:00 am
    Result: 8 months, 1 day, 1 hour
  • Start Date & Time: 2/20/2019 11:30 am
    End Date & Time: 10/20/2019 11:00 am
    Result: 7 months, 27 days, 23 hours, 30 minutes

I welcome any and all feedback! Especially if you see logical flaws or edge cases I haven't accounted for. However, I'm not terribly concerned with dates in the extreme past or future. Most everything I'm dealing with here will be in the 20th-21st century.

\$\endgroup\$
9
  • 2
    \$\begingroup\$ Can you provide the code for the TimeRemaining class or struct? \$\endgroup\$
    – Rick Davin
    Commented Feb 20, 2019 at 1:05
  • 2
    \$\begingroup\$ FYI nodatime.org is what I would recommend for such scenarios, because it takes into account all kinds of odd rules etc. nodatime.org/2.4.x/userguide/arithmetic \$\endgroup\$
    – BCdotWEB
    Commented Feb 20, 2019 at 11:04
  • 3
    \$\begingroup\$ Appending incremental edits just confuses the question. Since nobody has posted an answer yet, you can just edit the code to reflect your latest version. (Alternatively, you could post a self-answer instead, but then you would not be allowed to edit the question anymore.) \$\endgroup\$ Commented Feb 21, 2019 at 4:06
  • 3
    \$\begingroup\$ You have made so many incremental edits that I no longer can follow your question... \$\endgroup\$
    – t3chb0t
    Commented Feb 21, 2019 at 9:11
  • 1
    \$\begingroup\$ I've inlined to make the post legible. It would still be an improvement to replace the weird-locale text descriptions of the unit tests with actual unit test code (in particular, so that people suggesting changes can test them first). \$\endgroup\$ Commented Feb 21, 2019 at 15:32

1 Answer 1

5
\$\begingroup\$
public static TimeDifference SubtractDates(DateTime startDate, DateTime endDate)
{
    if (startDate <= endDate)
    {
       ... snip 170 lines ...
    }
    else
    {
        return new TimeDifference(0, 0, 0, 0, 0, 0, 0, 0);
    }
}

Apart from the fact that 177 lines is too long for a single method, it's cleaner to handle input validation directly at the top, not indent the main flow and then respond to the validation failure at the bottom. (Also, the test is a bit too conservative). Try:

public static TimeDifference SubtractDates(DateTime startDate, DateTime endDate)
{
    if (startDate >= endDate)
    {
        return new TimeDifference(0, 0, 0, 0, 0, 0, 0, 0);
    }

    ... snip 170 lines ...
}

        //identify median months between two dates
        List<DateTime> medianMonths = new List<DateTime>();

Huh? That variable doesn't seem to be touched anywhere else.


        //start with the first calendar month after the start date
        int monthsIterator = 1;
        DateTime iterativeMonth = startDate.AddMonths(monthsIterator);

        //total full months (we are going to return this)
        int months = 0;

        //continue counting months until you reach or surpass the end date
        while (iterativeMonth < endDate)
        {
            months++;
            monthsIterator++;

            //we use the iterator applied against the start month 
            //to account for edge cases like a start date of 1/31/2019 and a 
            //deadline of 3/31/2019.
            //
            //when adding "1 month" to 1/31/2019, c# will return 2/28/2019, so when you 
            //iterate the next month after, it will be 3/28/2019 instead of 3/31/2019.
            iterativeMonth = startDate.AddMonths(monthsIterator);
        }

Two things:

  1. What's the point of monthsIterator? It seems to me that you could remove it and just use months + 1 in its place.
  2. Why work forwards? Given the "lost days" business rule, it seems to me to be far more logical to work backwards.

That's as far as I can get before getting completely confused and giving up on understanding the code. I might be able to get further if it were refactored into short self-contained methods and if the comments explained the high-level "Why?" rather than the low-level "What?".


public class TimeDifference : IEquatable<TimeDifference>
{
    private int _days;
    private int _hours;
    ... etc ...

    public int Hours
    {
        get
        {
            return this._hours;
        }
        set
        {
            this._hours = value;
        }
    }

    ... etc ...

I can't see any reason not to use automatic properties - and if you use Visual Studio, it's probably telling you the same thing.


    public TimeDifference()
    {
        this.Years = 0;
        this.Months = 0;
        this.Days = 0;
        this.Hours = 0;
        this.Minutes = 0;
        this.Seconds = 0;
        this.Milliseconds = 0;
        this.DaysInMonth = 0;
    }

This is equivalent to

    public TimeDifference()
    {
    }

which is somewhat easier to read.


    public static bool operator ==(TimeDifference left, TimeDifference right)
    {
        bool status = false;
        if (left.Years == right.Years &&
            left.Months == right.Months &&
            left.Days == right.Days &&
            left.Hours == right.Hours &&
            left.Minutes == right.Minutes &&
            left.Seconds == right.Seconds &&
            left.Milliseconds == right.Milliseconds)
        {
            status = true;
        }
        return status;
    }

This is missing null checks. Also, I find it easier to read

return boolean-expression;

than

var result = false;
if (boolean-expression)
{
    result = true;
}
return result;

    public static bool operator !=(TimeDifference left, TimeDifference right)
    {
        bool status = false;
        if (left.Years != right.Years ||
            left.Months != right.Months ||
            left.Days != right.Days ||
            left.Hours != right.Hours ||
            left.Minutes != right.Minutes ||
            left.Seconds != right.Seconds ||
            left.Milliseconds != right.Milliseconds)
        {
            status = true;
        }
        return status;
    }

Duplicating == is a potential source of bugs. Far better to write

   public static bool operator !=(TimeDifference left, TimeDifference right) => !(left == right);

In fact, in my opinion it was an error in the language design that == and != are both required to be implemented: I think that only == should be required, and != should always be converted into !(... == ...) by the compiler.


    public bool Equals(TimeDifference other)
    {
        if (ReferenceEquals(null, other))
        {
            return false;
        }
        if (ReferenceEquals(this, other))
        {
            return true;
        }

        return this.Years.Equals(other.Years)
            && this.Months.Equals(other.Months)
            && this.Days.Equals(other.Days)
            && this.Hours.Equals(other.Hours)
            && this.Minutes.Equals(other.Minutes)
            && this.Seconds.Equals(other.Seconds)
            && this.Milliseconds.Equals(other.Milliseconds);
    }

This is the third essential copy of the same code. Don't repeat yourself.


    public override bool Equals(object obj)
    {
        if (ReferenceEquals(null, obj))
        {
            return false;
        }
        if (ReferenceEquals(this, obj))
        {
            return true;
        }

        return obj.GetType() == GetType() && Equals((TimeDifference)obj);
    }

GetType() is a bit over the top IMO.

public override bool Equals(object obj) => Equals(obj as TimeDifference);

is all you really need. If you're worried about someone making a subclass which breaks the symmetry of Equals, make the class sealed. (In fact, that's probably a good idea anyway: it's a strong hint to the compiler).


    public override int GetHashCode()
    {
        unchecked
        {
            int hashCode = Years.GetHashCode();
            hashCode = (hashCode * 397) ^ Months.GetHashCode();
            hashCode = (hashCode * 397) ^ Days.GetHashCode();
            hashCode = (hashCode * 397) ^ Hours.GetHashCode();
            hashCode = (hashCode * 397) ^ Minutes.GetHashCode();
            hashCode = (hashCode * 397) ^ Seconds.GetHashCode();
            hashCode = (hashCode * 397) ^ Milliseconds.GetHashCode();
            hashCode = (hashCode * 397) ^ DaysInMonth.GetHashCode();
            return hashCode;
        }
    }

I'm surprised that this should require unchecked.

\$\endgroup\$
3
  • \$\begingroup\$ Lots of great content here. Thank you! I'm going to look over all your critiques and have already implemented a few! \$\endgroup\$
    – imdandman
    Commented Feb 21, 2019 at 16:18
  • \$\begingroup\$ Do you have any suggestions about what to split out into extra methods? I'm not opposed to the idea, I just don't know where it makes sense to do so. \$\endgroup\$
    – imdandman
    Commented Feb 21, 2019 at 16:21
  • \$\begingroup\$ Pick the comment which covers the most lines of code (perhaps "construct false "end date" with the same day of the month in the month immediately following the start date"?). That's probably a method... Alternatively, if you rework in line with my observation about going backwards rather than forwards, the structure might be something like diff.Years = ExtractYears(startDate, endDate); endDate -= TimeSpan.FromYears(diff.Years); diff.Months = ExtractMonths(startDate, endDate); endDate = ... \$\endgroup\$ Commented Feb 21, 2019 at 16:58

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.