开发者

A Real Timespan Object With .Years & .Months

Consider the following 2 scenarios: Scenario 1). Today is May 1st 2012, and Scenario 2). Today is September 1st 2012.

Now, consider that we write on our webpage the following about a comment someone has left: "This comment was written 3 months and 12 days ago". The amount of days in both these scenarios will ALWAYS be different even though the statement is exactly the same. In Scenario 1, "3 months and 12 days" would equal 102 days. However, in Scenario 2, "3 months and 12 days" would be 104 days!

Now, to corner in on my point, lets use a different example and say that someone left a comment on our site on Jan 30th 2013, and today is March 10th 2013. Our real TimeSpan object needs to know this relative date, and can figure out the following:

  • That there is 10 days in March,
  • That there is 1 day in Jan (counting from 30th to 31st).
  • That the month Feb is one month regardless of how many days there are in it (even though it's 28 days).

So, it would mean 10 days + 1 day + 1 month total, translating to This comment was posted 1 Month and 11 Days ago.

Now, if you used the MS style TimeSpan object (or any TimeSpan object in any language), it would give you the number of days from 30th Jan to 10 March (39 days), and because the TimeSpan object doesn't store relative date (the base/initial date we subtracted to get the TimeSpan), if you asked it how many months and days it has been, it will assume there is 30 days in one month, or even worst, the average which is greater than 30 days, and return the rest in days, so to get to 39 days, it will tell you it's been 1 Month and 9 Days and you will get the This comment was posted 1 Month and 9 Days ago message. Remember, both these scenarios have the same start date and same current/end date, yes the Microsoft TimeSpan object, by not allowing us to tell it the month of Feb 2013 should be considered, has given us a completely different TimeSpan, off by a whole 2 days. It has, in effect, lied to us.

The problem is, people will believe this, and who knows what perceptions they may have, how their perceptions of the past may change and the decisions & life choices they may make when trying to reconstruct events within the past inside their own minds, while never noticing or understanding the drawback and inherent failure of representing time that is so pervasive everywhere today. They will not understand that programming languages don't realize (or care) that last month had 31 days in it, as oppposed to 30, 29 or 28 - or visa versa, and that this adds up when you increase the TimeSpan.

This is the problem at the heart of this post. I understand that most people will not care about this difference (but be sure that some of us do, and cannot have this on our backs), and if this doesn't bother you, thats ok. I wish it didn't bother me, I would have saved myself some time, stress and disappointment. If this is not a bother, you can use the function for the efficient textual display of relative time (customizable to 1 to 6 nodes from seconds to years), instead of using it for the usually negligible accuracy it provides.

To my disappointment I noticed that there is no real timespan object, if you get a timespan, and do a .years or .months you'll get nothing, you'll only get .days and lower because a timeSpan object doesn't carry anything to tell it which month or year the timeSpan was created on. Therefore it'll never really know how many months it's been since days in each month vary over a year and even further over a leap year.

In response to this, I'll post a function I developed in order to get ACCURATE readings and be able to return things like the following on my ASP.NET web page...

Posted 4 years, 3 months, 14 days, 15 hours, 18 minutes and 24 seconds ago

I figured there'd be a …

timeSpan.GetActualNumberOf[Months/Days/Hours/etc] (base date must be provided of course)

… type method on this datatype, but there wasn't.

All you'd really have to do is create another property on the timeSpan object to give it a base date on which the difference was calculated, then the above lovely string would be calculable pr开发者_StackOverflow社区etty easily, and a .year & .month would exist!

UPDATE: I have significantly expanded upon and updated my official answer and code usage details in my answer below, 100% working answer and code (in full), accurate and exact relative time/dates, no approximations - thanks.


Here's how to add some extension methods for this with C# using mean values:

public static class TimeSpanExtensions
{
    public static int GetYears(this TimeSpan timespan)
    {
        return (int)(timespan.Days/365.2425);
    }
    public static int GetMonths(this TimeSpan timespan)
    {
        return (int)(timespan.Days/30.436875);
    }
}


What you are looking for is indeed not what TimeSpan represents. TimeSpan represents an interval as a count of ticks, without respect to a base DateTime or Calendar.

A new DateDifference type might make more sense here, with a constructor or factory method taking a base DateTime, a target DateTime, and optionally a Calendar (defaulting to CultureInfo.CurrentCulture) with which to compute the various difference components (years, months, etc.)

EDIT: It looks to me like Noda Time may have the tools you need for this — the Period class "[r]epresents a period of time expressed in human chronological terms: hours, days, weeks, months and so on", and in particular Period.Between(then, now, PeriodUnits.AllUnits) seems to be the precise calculation you're asking for — but it's necessarily a much more complex class than TimeSpan. The Key Concepts page on the Noda Time wiki explains how "humans make time messy":

Leaving aside the tricky bits of astronomy and relativity, mankind has still made time hard to negotiate. If we all used ticks from the Unix epoch to talk about time, there wouldn't be a need for a library like Noda Time.

But no, we like to talk in years, months, days, weeks - and for some reason we like 12pm (which confusingly comes before 1pm) to be roughly the time at which the sun is highest... so we have time zones.

Not only that, but we don't all agree on how many months there are. Different civilizations have come up with different ways of splitting up the year, and different numbers for the years to start with. These are calendar systems.


Well, better late then nothing I suppose ;)

C# function giving everything

And this is my modified version :

private string GetElapsedTime(DateTime from_date, DateTime to_date) {
int years;
int months;
int days;
int hours;
int minutes;
int seconds;
int milliseconds;

//------------------
// Handle the years.
//------------------
years = to_date.Year - from_date.Year;

//------------------------
// See if we went too far.
//------------------------
DateTime test_date = from_date.AddMonths(12 * years);

if (test_date > to_date)
{
    years--;
    test_date = from_date.AddMonths(12 * years);
}

//--------------------------------
// Add months until we go too far.
//--------------------------------
months = 0;

while (test_date <= to_date)
{
    months++;
    test_date = from_date.AddMonths(12 * years + months);
}

months--;

//------------------------------------------------------------------
// Subtract to see how many more days, hours, minutes, etc. we need.
//------------------------------------------------------------------
from_date = from_date.AddMonths(12 * years + months);
TimeSpan remainder = to_date - from_date;
days = remainder.Days;
hours = remainder.Hours;
minutes = remainder.Minutes;
seconds = remainder.Seconds;
milliseconds = remainder.Milliseconds;

return (years > 0 ? years.ToString() + " years " : "") +
       (months > 0 ? months.ToString() + " months " : "") +
       (days > 0 ? days.ToString() + " days " : "") +
       (hours > 0 ? hours.ToString() + " hours " : "") +
       (minutes > 0 ? minutes.ToString() + " minutes " : "");}


Here is the main answer with code, please note that you can get any number of dates/times accuracy, seconds & minutes, or seconds, minutes and days, anywhere up to years (which would contain 6 parts/segments). If you specify top two and it's over a year old, it will return "1 year and 3 months ago" and won't return the rest because you've requested two segments. if it's only a few hours old, then it will only return "2 hours and 1 minute ago". Of course, same rules apply if you specify 1, 2, 3, 4, 5 or 6 segmets (maxes out at 6 because seconds, minutes, hours, days, months, years only make 6 types). It will also correct grammer issues like "minutes" vs "minute" depending on if it's 1 minute or more, same for all types, and the "string" generated will always be grammatically correct.

Here are some examples for use: bAllowSegments identifies how many segments to show... ie: if 3, then return string would be (as an example)... "3 years, 2 months and 13 days" (won't include hours, minutes and seconds as the top 3 time categories are returned), if however, the date was a newer date, such as something a few days ago, specifying the same segments (3) will return "4 days, 1 hour and 13 minutes ago" instead, so it takes everything into account!

if bAllowSegments is 2 it would return "3 years and 2 months" and if 6 (maximum value) would return "3 years, 2 months, 13 days, 13 hours, 29 minutes and 9 seconds", but, be reminded that it will NEVER RETURN something like this "0 years, 0 months, 0 days, 3 hours, 2 minutes and 13 seconds ago" as it understands there is no date data in the top 3 segments and ignores them, even if you specify 6 segments, so don't worry :). Of course, if there is a segment with 0 in it, it will take that into account when forming the string, and will display as "3 days and 4 seconds ago" and ignoring the "0 hours" part! Enjoy and please comment if you like.

 Public Function RealTimeUntilNow(ByVal dt As DateTime, Optional ByVal bAllowSegments As Byte = 2) As String
  ' bAllowSegments identifies how many segments to show... ie: if 3, then return string would be (as an example)...
  ' "3 years, 2 months and 13 days" the top 3 time categories are returned, if bAllowSegments is 2 it would return
  ' "3 years and 2 months" and if 6 (maximum value) would return "3 years, 2 months, 13 days, 13 hours, 29 minutes and 9 seconds"
  Dim rYears, rMonths, rDays, rHours, rMinutes, rSeconds As Int16
  Dim dtNow = DateTime.Now
  Dim daysInBaseMonth = Date.DaysInMonth(dt.Year, dt.Month)

  rYears = dtNow.Year - dt.Year
  rMonths = dtNow.Month - dt.Month
  If rMonths < 0 Then rMonths += 12 : rYears -= 1 ' add 1 year to months, and remove 1 year from years.
  rDays = dtNow.Day - dt.Day
  If rDays < 0 Then rDays += daysInBaseMonth : rMonths -= 1
  rHours = dtNow.Hour - dt.Hour
  If rHours < 0 Then rHours += 24 : rDays -= 1
  rMinutes = dtNow.Minute - dt.Minute
  If rMinutes < 0 Then rMinutes += 60 : rHours -= 1
  rSeconds = dtNow.Second - dt.Second
  If rSeconds < 0 Then rSeconds += 60 : rMinutes -= 1

  ' this is the display functionality
  Dim sb As StringBuilder = New StringBuilder()
  Dim iSegmentsAdded As Int16 = 0

  If rYears > 0 Then sb.Append(rYears) : sb.Append(" year" & If(rYears <> 1, "s", "") & ", ") : iSegmentsAdded += 1
  If bAllowSegments = iSegmentsAdded Then GoTo parseAndReturn

  If rMonths > 0 Then sb.AppendFormat(rMonths) : sb.Append(" month" & If(rMonths <> 1, "s", "") & ", ") : iSegmentsAdded += 1
  If bAllowSegments = iSegmentsAdded Then GoTo parseAndReturn

  If rDays > 0 Then sb.Append(rDays) : sb.Append(" day" & If(rDays <> 1, "s", "") & ", ") : iSegmentsAdded += 1
  If bAllowSegments = iSegmentsAdded Then GoTo parseAndReturn

  If rHours > 0 Then sb.Append(rHours) : sb.Append(" hour" & If(rHours <> 1, "s", "") & ", ") : iSegmentsAdded += 1
  If bAllowSegments = iSegmentsAdded Then GoTo parseAndReturn

  If rMinutes > 0 Then sb.Append(rMinutes) : sb.Append(" minute" & If(rMinutes <> 1, "s", "") & ", ") : iSegmentsAdded += 1
  If bAllowSegments = iSegmentsAdded Then GoTo parseAndReturn

  If rSeconds > 0 Then sb.Append(rSeconds) : sb.Append(" second" & If(rSeconds <> 1, "s", "") & "") : iSegmentsAdded += 1

parseAndReturn:

  ' if the string is entirely empty, that means it was just posted so its less than a second ago, and an empty string getting passed will cause an error
  ' so we construct our own meaningful string which will still fit into the "Posted * ago " syntax...

  If sb.ToString = "" Then sb.Append("less than 1 second")

  Return ReplaceLast(sb.ToString.TrimEnd(" ", ",").ToString, ",", " and")

 End Function

Of course, you will need a "ReplaceLast" function, which takes a source string, and an argument specifying what needs to be replaced, and another arg specifying what you want to replace it with, and it only replaces the last occurance of that string... i've included my one if you don't have one or dont want to implement it, so here it is, it will work "as is" with no modification needed. I know the reverseit function is no longer needed (exists in .net) but the ReplaceLast and the ReverseIt func are carried over from the pre-.net days, so please excuse how dated it may look (still works 100% tho, been using em for over ten years, can guarante they are bug free)... :). Also, if you are using VB6, you can use StrReverse (wrapping it around the string extended with the .ReverseIt extension method), instead of using the ReverseIt() function (provided as an extension method). So, instead of doing sReplacable.ReverseIt, you'd do StrReverse(sReplacable) as StrReverse() is a built in VB6 function (and does the exact same thing, reverses a given string, and does nothing more). If you use StrReverse() instead of my generic ReverseIt function, feel free to delete the ReverseIt function/extension. StrReverse() function should be available in .NET as long as you are importing the legacy ms-visualbasic-dll library. Makes no difference either way, I had written ReverseIt() before I even know a StrReverse() function had existed, and had been using it ever since out of habit (no real reason to use mine as opposed to the in-built generic function StrReverse) - in fact, I'm sure StrReverse (or a similar, newer .NET specific version of a string reversing function) would be written to be more efficient :). cheers.

<Extension()> _ 
Public Function ReplaceLast(ByVal sReplacable As String, ByVal sReplaceWhat As String, ByVal sReplaceWith As String) As String 
    ' let empty string arguments run, incase we dont know if we are sending and empty string or not. 
    sReplacable = sReplacable.ReverseIt 
    sReplacable = Replace(sReplacable, sReplaceWhat.ReverseIt, sReplaceWith.ReverseIt, , 1) ' only does first item on reversed version! 
    Return sReplacable.ReverseIt.ToString 
End Function 

<Extension()> _ 
Public Function ReverseIt(ByVal strS As String, Optional ByVal n As Integer = -1) As String 
    Dim strTempX As String = "", intI As Integer 

    If n > strS.Length Or n = -1 Then n = strS.Length 

    For intI = n To 1 Step -1 
        strTempX = strTempX + Mid(strS, intI, 1) 
    Next intI 

    ReverseIt = strTempX + Right(strS, Len(strS) - n) 

End Function 


Using .Net 4.5 and the CultureInfo class, one can add months and years to a given date.

DateTime datetime = DateTime.UtcNow;
int years = 15;
int months = 7;

DateTime yearsAgo = CultureInfo.InvariantCulture.Calendar.AddYears(datetime, -years);
DateTime monthsInFuture = CultureInfo.InvariantCulture.Calendar.AddMonths(datetime, months);

Since that's a lot of typing, I prefer to create extension methods:

public static DateTime AddYears(this DateTime datetime, int years)
{
    return CultureInfo.InvariantCulture.Calendar.AddYears(datetime, years);
}

public static DateTime AddMonths(this DateTime datetime, int months)
{
    return CultureInfo.InvariantCulture.Calendar.AddMonths(datetime, months);
}

DateTime yearsAgo = datetime.AddYears(-years);
DateTime monthsInFuture = datetime.AddMonths(months);


I would say that the current TimeSpan is a real timespan object, i.e., the amount of time between Jan 1 2008 1:31 a.m. and Feb. 3, 2008 at 6:45 a.m. is the same as the amount of time between Feb. 5, 2008 at 1:45 p.m. and March 9, 2008 at 6:59 p.m.. What you are looking for is in actuality the difference between two datetimes.

As for the .MakeMagicHappen.gimmeSomethingPretty.surelyMShasThoughtAboutThisDilema to fulfill the specific needs of your system, that's why people hire you as a programmer. If the framework you are using does absolutely everything, your company would just be able to presss a single button and their system would pop out fully formed and you'd be on the unemployment line along with the rest of us programmers.


I believe that the following method is pretty trusteable and straightforward, since it's based on the framework date calculation and returns a readable elapsed time strings like Facebook's ones. Sorry about the little portuguese words and plural treatment, in my case it was necessary.

public static string ElapsedTime(DateTime dtEvent)
{
    TimeSpan TS = DateTime.Now - dtEvent;

    int intYears = TS.Days / 365;
    int intMonths = TS.Days / 30;
    int intDays = TS.Days;
    int intHours = TS.Hours;
    int intMinutes = TS.Minutes;
    int intSeconds = TS.Seconds;

    if (intYears > 0) return String.Format("há {0} {1}", intYears, (intYears == 1) ? "ano" : "anos");
    else if (intMonths > 0) return String.Format("há {0} {1}", intMonths, (intMonths == 1) ? "mês" : "meses");
    else if (intDays > 0) return String.Format("há {0} {1}", intDays, (intDays == 1) ? "dia" : "dias");
    else if (intHours > 0) return String.Format("há ± {0} {1}", intHours, (intHours == 1) ? "hora" : "horas");
    else if (intMinutes > 0) return String.Format("há ± {0} {1}", intMinutes, (intMinutes == 1) ? "minuto" : "minutos");
    else if (intSeconds > 0) return String.Format("há ± {0} {1}", intSeconds, (intSeconds == 1) ? "segundo" : "segundos");
    else
    {
        return String.Format("em {0} às {1}", dtEvent.ToShortDateString(), dtEvent.ToShortTimeString());
    }
}


I took the accepted answer and converted it from VB.Net to C# and made a few modification/improvements as well. I got rid of the string reversals, which were being used to replace the last instance of a string, and used an extension method that more directly finds and replaces the last instance of a string.

Example of how to call the method:

PeriodBetween(#2/28/2011#, DateTime.UtcNow, 6)

Main Method:

public static string PeriodBetween(DateTime then, DateTime now, byte numberOfPeriodUnits = 2)
{
    // Translated from VB.Net to C# from: https://stackoverflow.com/a/1956265

    // numberOfPeriodUnits identifies how many time period units to show.
    // If numberOfPeriodUnits = 3, function would return:
    //      "3 years, 2 months and 13 days"
    // If numberOfPeriodUnits = 2, function would return:
    //      "3 years and 2 months"
    // If numberOfPeriodUnits = 6, (maximum value), function would return:
    //      "3 years, 2 months, 13 days, 13 hours, 29 minutes and 9 seconds"

    if (numberOfPeriodUnits > 6 || numberOfPeriodUnits < 1)
    {
        throw new ArgumentOutOfRangeException($"Parameter [{nameof(numberOfPeriodUnits)}] is out of bounds. Valid range is 1 to 6.");
    }

    short Years = 0;
    short Months = 0;
    short Days = 0;
    short Hours = 0;
    short Minutes = 0;
    short Seconds = 0;
    short DaysInBaseMonth = (short)(DateTime.DaysInMonth(then.Year, then.Month));

    Years = (short)(now.Year - then.Year);

    Months = (short)(now.Month - then.Month);
    if (Months < 0)
    {
        Months += 12;
        Years--; // add 1 year to months, and remove 1 year from years.
    }

    Days = (short)(now.Day - then.Day);
    if (Days < 0)
    {
        Days += DaysInBaseMonth;
        Months--;
    }

    Hours = (short)(now.Hour - then.Hour);
    if (Hours < 0)
    {
        Hours += 24;
        Days--;
    }

    Minutes = (short)(now.Minute - then.Minute);
    if (Minutes < 0)
    {
        Minutes += 60;
        Hours--;
    }

    Seconds = (short)(now.Second - then.Second);
    if (Seconds < 0)
    {
        Seconds += 60;
        Minutes--;
    }

    // This is the display functionality.
    StringBuilder TimePeriod = new StringBuilder();
    short NumberOfPeriodUnitsAdded = 0;

    if (Years > 0)
    {
        TimePeriod.Append(Years);
        TimePeriod.Append(" year" + (Years != 1 ? "s" : "") + ", ");
        NumberOfPeriodUnitsAdded++;
    }

    if (numberOfPeriodUnits == NumberOfPeriodUnitsAdded)
    {
        goto ParseAndReturn;
    }

    if (Months > 0)
    {
        TimePeriod.AppendFormat(Months.ToString());
        TimePeriod.Append(" month" + (Months != 1 ? "s" : "") + ", ");
        NumberOfPeriodUnitsAdded++;
    }

    if (numberOfPeriodUnits == NumberOfPeriodUnitsAdded)
    {
        goto ParseAndReturn;
    }

    if (Days > 0)
    {
        TimePeriod.Append(Days);
        TimePeriod.Append(" day" + (Days != 1 ? "s" : "") + ", ");
        NumberOfPeriodUnitsAdded++;
    }

    if (numberOfPeriodUnits == NumberOfPeriodUnitsAdded)
    {
        goto ParseAndReturn;
    }

    if (Hours > 0)
    {
        TimePeriod.Append(Hours);
        TimePeriod.Append(" hour" + (Hours != 1 ? "s" : "") + ", ");
        NumberOfPeriodUnitsAdded++;
    }

    if (numberOfPeriodUnits == NumberOfPeriodUnitsAdded)
    {
        goto ParseAndReturn;
    }

    if (Minutes > 0)
    {
        TimePeriod.Append(Minutes);
        TimePeriod.Append(" minute" + (Minutes != 1 ? "s" : "") + ", ");
        NumberOfPeriodUnitsAdded++;
    }

    if (numberOfPeriodUnits == NumberOfPeriodUnitsAdded)
    {
        goto ParseAndReturn;
    }

    if (Seconds > 0)
    {
        TimePeriod.Append(Seconds);
        TimePeriod.Append(" second" + (Seconds != 1 ? "s" : "") + "");
        NumberOfPeriodUnitsAdded++;
    }

    ParseAndReturn:
    // If the string is empty, that means the datetime is less than a second in the past.
    // An empty string being passed will cause an error, so we construct our own meaningful
    // string which will still fit into the "Posted * ago " syntax.

    if (TimePeriod.ToString() == "")
    {
        TimePeriod.Append("less than 1 second");
    }

    return TimePeriod.ToString().TrimEnd(' ', ',').ToString().ReplaceLast(",", " and");
}

ReplaceLast Extension Method:

public static string ReplaceLast(this string source, string search, string replace)
{
    int pos = source.LastIndexOf(search);

    if (pos == -1)
    {
        return source;
    }

    return source.Remove(pos, search.Length).Insert(pos, replace);
}
0

上一篇:

下一篇:

精彩评论

暂无评论...
验证码 换一张
取 消

最新问答

问答排行榜