TimeSpan to friendly string library (C#)
Asked Answered
I

10

15

Does anyone know of a good library (or code snippet) for converting a TimeSpan object to a "friendly" string such as:

  • Two years, three months and four days
  • One week and two days

(It's for a document expiry system, where the expiry could be anything from a few days to several decades)

Just to clarify, say I had a TimeSpan with 7 days, that should print "1 week", 14 days "2 weeks", 366 days "1 year and 1 day", etc etc.

Interjection answered 16/7, 2009 at 16:19 Comment(2)
possible duplicate of Calculating relative timeSecond
check my answer with exampleCampney
L
15

Not a fully featured implementation, but it should get you close enough.

DateTime dtNow = DateTime.Now;
DateTime dtYesterday = DateTime.Now.AddDays(-435.0);
TimeSpan ts = dtNow.Subtract(dtYesterday);

int years = ts.Days / 365; //no leap year accounting
int months = (ts.Days % 365) / 30; //naive guess at month size
int weeks = ((ts.Days % 365) % 30) / 7;
int days = (((ts.Days % 365) % 30) % 7);

StringBuilder sb = new StringBuilder();
if(years > 0)
{
    sb.Append(years.ToString() + " years, ");
}
if(months > 0)
{
    sb.Append(months.ToString() + " months, ");
}
if(weeks > 0)
{
    sb.Append(weeks.ToString() + " weeks, ");
}
if(days > 0)
{
    sb.Append(days.ToString() + " days.");
}
string FormattedTimeSpan = sb.ToString();

In the end, do you really need to let someone know a document is going to expire exactly 1 year, 5 months, 2 weeks, and 3 days from now? Can't you get by with telling them the document will expire over 1 year from now, or over 5 months from now? Just take the largest unit and say over n of that unit.

Lorrin answered 16/7, 2009 at 16:44 Comment(4)
Smashing! Cheers for that. Yeah, I think you're right about making the value a bit fuzzier, but it's one of those things where I'll need to test it on a variety of values and see what works out as a sensible middle-ground between too little and too much detail.Interjection
Thanks Patrick McDonald. I totally missed my copy paste error.Lorrin
@Lorrin You're losing some of the benefits of the StringBuilder by concatenating the strings you're putting into it the traditional way. You should use .Append twice (FYI, it chains. i.e. sb.Append(years.ToString()).Append(" years, ");).Rapprochement
Or I like sb.AppendFormat("{0} years, ", years);Augmentation
C
18

I just stumbled upon this question because I wanted to do a similar thing. After some googling I still didn't find what I wanted: display a timespan in a sort of "rounded" fashion. I mean: when some event took several days, it doesn't always make sense to display the milliseconds. However, when it took minutes, it probably does. And in that case, I don't want 0 days and 0 hours to be displayed. So, I want to parametrize the number of relevant timespan parts to be displayed. This resulted in this bit of code:

public static class TimeSpanExtensions
{
    private enum TimeSpanElement
    {
        Millisecond,
        Second,
        Minute,
        Hour,
        Day
    }

    public static string ToFriendlyDisplay(this TimeSpan timeSpan, int maxNrOfElements)
    {
        maxNrOfElements = Math.Max(Math.Min(maxNrOfElements, 5), 1);
        var parts = new[]
                        {
                            Tuple.Create(TimeSpanElement.Day, timeSpan.Days),
                            Tuple.Create(TimeSpanElement.Hour, timeSpan.Hours),
                            Tuple.Create(TimeSpanElement.Minute, timeSpan.Minutes),
                            Tuple.Create(TimeSpanElement.Second, timeSpan.Seconds),
                            Tuple.Create(TimeSpanElement.Millisecond, timeSpan.Milliseconds)
                        }
                                    .SkipWhile(i => i.Item2 <= 0)
                                    .Take(maxNrOfElements);

        return string.Join(", ", parts.Select(p => string.Format("{0} {1}{2}", p.Item2, p.Item1, p.Item2 > 1 ? "s" : string.Empty)));
    }
}

Example (LinqPad):

new TimeSpan(1,2,3,4,5).ToFriendlyDisplay(3).Dump();
new TimeSpan(0,5,3,4,5).ToFriendlyDisplay(3).Dump();

Displays:

1 Day, 2 Hours, 3 Minutes
5 Hours, 3 Minutes, 4 Seconds

Suits me, see if it suits you.

Capriccioso answered 26/8, 2011 at 11:38 Comment(6)
Interesting idea, I'll try it out (belatedly) :)Interjection
@Gert, I was just trying out your extension method. Passed 8 hours as the TimeSpan and got this as the result "8 Hours, 0 Minute, 0 Second, 0 Millisecond". Shouldn't those zero's not be displayed as you said in your post? Thanks in advance!Fermi
@Fermi I meant the leading 0 parts. If you don't want any 0 part to be displayed you can add .Where(i => i.Item2 != 0 after the Take.Capriccioso
Ah okay, my bad. That's exactly what I want, thanks!Fermi
@Fermi / @Gert-Arnold wouldn't replacing the .SkipWhile(i => i.Item2 <= 0) with .Where(i => i.Item2 > 0) do the same with a few less cycles (I'm talking specifically about if you don't want any 0 part to be displayed)? Also see my answer regarding the Humanizer project.Biscuit
@Biscuit .SkipWhile(i => i.Item2 <= 0) is effective but yields different results than using the .Where(i => i.Item2 >0). The latter will remove ALL zero entries. .SkipWhile will only remove the leading zero values.Flaherty
L
15

Not a fully featured implementation, but it should get you close enough.

DateTime dtNow = DateTime.Now;
DateTime dtYesterday = DateTime.Now.AddDays(-435.0);
TimeSpan ts = dtNow.Subtract(dtYesterday);

int years = ts.Days / 365; //no leap year accounting
int months = (ts.Days % 365) / 30; //naive guess at month size
int weeks = ((ts.Days % 365) % 30) / 7;
int days = (((ts.Days % 365) % 30) % 7);

StringBuilder sb = new StringBuilder();
if(years > 0)
{
    sb.Append(years.ToString() + " years, ");
}
if(months > 0)
{
    sb.Append(months.ToString() + " months, ");
}
if(weeks > 0)
{
    sb.Append(weeks.ToString() + " weeks, ");
}
if(days > 0)
{
    sb.Append(days.ToString() + " days.");
}
string FormattedTimeSpan = sb.ToString();

In the end, do you really need to let someone know a document is going to expire exactly 1 year, 5 months, 2 weeks, and 3 days from now? Can't you get by with telling them the document will expire over 1 year from now, or over 5 months from now? Just take the largest unit and say over n of that unit.

Lorrin answered 16/7, 2009 at 16:44 Comment(4)
Smashing! Cheers for that. Yeah, I think you're right about making the value a bit fuzzier, but it's one of those things where I'll need to test it on a variety of values and see what works out as a sensible middle-ground between too little and too much detail.Interjection
Thanks Patrick McDonald. I totally missed my copy paste error.Lorrin
@Lorrin You're losing some of the benefits of the StringBuilder by concatenating the strings you're putting into it the traditional way. You should use .Append twice (FYI, it chains. i.e. sb.Append(years.ToString()).Append(" years, ");).Rapprochement
Or I like sb.AppendFormat("{0} years, ", years);Augmentation
J
4

I know this is old, but I wanted to answer with a great nuget package.

Install-Package Humanizer

https://www.nuget.org/packages/Humanizer

https://github.com/MehdiK/Humanizer

Example from their readme.md

TimeSpan.FromMilliseconds(1299630020).Humanize(4) => "2 weeks, 1 day, 1 hour, 30 seconds"

@ian-becker Needs the credit

Janice answered 15/5, 2020 at 7:49 Comment(0)
M
3

The TimeSpan object has Days, Hours, Minutes, and Seconds properties on it, so it wouldn't be too hard to make a snippet that formats those values to a friendly string.

Unfortunately Days is the largest value. Anything longer than that and you'll have to start worrying about days in a month for every year...etc. You're better off stopping at days in my opinion (the added effort doesn't seem worth the gain).

UPDATE

...I figured I'd bring this up from my own comment:

Understandable, but is "This document expires in 10 years, 3 months, 21 days, 2 hours, and 30 minutes" really any more helpful or less silly? If it were up to me, since neither representation seems very useful...

I'd leave off the timespan for expiry until the date got reasonably close (30 or 60 days maybe if you're worried about getting the document updated).

Seems a much better UX choice to me.

Mensa answered 16/7, 2009 at 16:29 Comment(2)
Days is fine for saying "This document expires in 7 days", but when it's "This document expires in 3892 days" then that looks a bit silly (and unhelpful). I can see how I'd use modulo to do this, but I'm lazy and don't want to spend time re-inventing the wheel :)Interjection
Understandable, but is "This document expires in 10 years, 3 months, 21 days, 2 hours, and 30 minutes" really any more helpful or less silly? If it were up to me, since neither representation seems very useful...I'd leave off the timespan for expiry until the date got reasonably close (30 or 60 days maybe if you're worried about getting the document updated). Seems a much better UX choice to me.Mensa
B
3

There is now also the Humanizer project that looks very interesting that can do this and way more.

Biscuit answered 25/5, 2015 at 22:21 Comment(0)
T
3

Here is my solution to this. It is based on other answers in this thread, with added support for year and month as that was requested in the original question (and was what I needed).

As for the discussion whether or not this makes sense I would say that there are cases where it does so. In my case we wanted to show the duration of agreements that in some cases are just a few days, and in other cases several years.

Tests;

[Test]
public void ToFriendlyDuration_produces_expected_result()
{
    new DateTime(2019, 5, 28).ToFriendlyDuration(null).Should().Be("Until further notice");
    new DateTime(2019, 5, 28).ToFriendlyDuration(new DateTime(2020, 5, 28)).Should().Be("1 year");
    new DateTime(2019, 5, 28).ToFriendlyDuration(new DateTime(2021, 5, 28)).Should().Be("2 years");
    new DateTime(2019, 5, 28).ToFriendlyDuration(new DateTime(2021, 8, 28)).Should().Be("2 years, 3 months");
    new DateTime(2019, 5, 28).ToFriendlyDuration(new DateTime(2019, 8, 28)).Should().Be("3 months");
    new DateTime(2019, 5, 28).ToFriendlyDuration(new DateTime(2019, 8, 31)).Should().Be("3 months, 3 days");
    new DateTime(2019, 5, 1).ToFriendlyDuration(new DateTime(2019, 5, 31)).Should().Be("30 days");
    new DateTime(2010, 5, 28).ToFriendlyDuration(new DateTime(2020, 8, 28)).Should().Be("10 years, 3 months");
    new DateTime(2010, 5, 28).ToFriendlyDuration(new DateTime(2020, 5, 29)).Should().Be("10 years, 1 day");
}

Implementation;

private class TermAndValue
{
    public TermAndValue(string singular, string plural, int value)
    {
        Singular = singular;
        Plural = plural;
        Value = value;
    }

    public string Singular { get; }
    public string Plural { get; }
    public int Value { get; }
    public string Term => Value > 1 ? Plural : Singular;
}

public static string ToFriendlyDuration(this DateTime value, DateTime? endDate, int maxNrOfElements = 2)
{
    if (!endDate.HasValue)
        return "Until further notice";

    var extendedTimeSpan = new TimeSpanWithYearAndMonth(value, endDate.Value);
    maxNrOfElements = Math.Max(Math.Min(maxNrOfElements, 5), 1);
    var termsAndValues = new[]
    {
        new TermAndValue("year", "years", extendedTimeSpan.Years),
        new TermAndValue("month", "months", extendedTimeSpan.Months),
        new TermAndValue("day", "days", extendedTimeSpan.Days),
        new TermAndValue("hour", "hours", extendedTimeSpan.Hours),
        new TermAndValue("minute", "minutes", extendedTimeSpan.Minutes)
    };

    var parts = termsAndValues.Where(i => i.Value != 0).Take(maxNrOfElements);

    return string.Join(", ", parts.Select(p => $"{p.Value} {p.Term}"));
}

internal class TimeSpanWithYearAndMonth
{
    internal TimeSpanWithYearAndMonth(DateTime startDate, DateTime endDate)
    {
        var span = endDate - startDate;

        Months = 12 * (endDate.Year - startDate.Year) + (endDate.Month - startDate.Month);
        Years = Months / 12;
        Months -= Years * 12;

        if (Months == 0 && Years == 0)
        {
            Days = span.Days;
        }
        else
        {
            var startDateExceptYearsAndMonths = startDate.AddYears(Years);
            startDateExceptYearsAndMonths = startDateExceptYearsAndMonths.AddMonths(Months);
            Days = (endDate - startDateExceptYearsAndMonths).Days;
        }

        Hours = span.Hours;
        Minutes = span.Minutes;
    }

    public int Minutes { get; }
    public int Hours { get; }
    public int Days { get; }
    public int Years { get; }
    public int Months { get; }
}
Thermion answered 28/5, 2019 at 12:2 Comment(0)
Z
1

It probably won't do everything you are looking for, but in v4 Microsoft will be implementing IFormattable on TimeSpan.

Zena answered 16/7, 2009 at 16:22 Comment(1)
great to know. here's the MSDN page for .NET 4 msdn.microsoft.com/en-us/library/dd992632(VS.100).aspxSeason
N
0

To format any period longer than 1 day (i.e. month/year/decade etc.) a Timespan object is not enough.

Suppose your timespan is 35 days, then from Apr 1 you would get one month and five days, whereas from Dec 1 you would get one month and four days.

Neve answered 16/7, 2009 at 16:37 Comment(0)
K
0

Yes! I needed the same so many times then now I create my onw package and published it in Nuget. You are welcome to use it. The package name is EstecheAssemblies

It's easy to implement:

using EstecheAssemblies;  

var date = new DateTime("2019-08-08 01:03:21");  
var text = date.ToFriendlyTimeSpan();
Kahler answered 28/9, 2019 at 20:1 Comment(0)
S
-1

See how-do-i-calculate-relative-time, asked (by user number 1) a year ago when SO was young and not public. It was (probably) the basis for the current age display for SO questions and answers.

Salinger answered 16/7, 2009 at 16:46 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.