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.
- 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
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.
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.
TimeRemaining
class or struct? \$\endgroup\$