Michael Mankus
Michael Mankus

Reputation: 4778

How to produce "human readable" strings to represent a TimeSpan

I have a TimeSpan representing the amount of time a client has been connected to my server. I want to display that TimeSpan to the user. But I don't want to be overly verbose to displaying that information (ex: 2hr 3min 32.2345sec = too detailed!)

For example: If the connection time is...

> 0 seconds and < 1 minute   ----->  0 Seconds
> 1 minute  and < 1 hour     ----->  0 Minutes, 0 Seconds
> 1 hour    and < 1 day      ----->  0 Hours, 0 Minutes
> 1 day                      ----->  0 Days, 0 Hours

And of course, in cases where the numeral is 1 (ex: 1 seconds, 1 minutes, 1 hours, 1 days), I would like to make the text singular (ex: 1 second, 1 minute, 1 hour, 1 day).

Is there anyway to easily implement this without a giant set of if/else clauses? Here is what I'm currently doing.

public string GetReadableTimeSpan(TimeSpan value)
{
    string duration;

    if (value.TotalMinutes < 1)
        duration = value.Seconds + " Seconds";
    else if (value.TotalHours < 1)
        duration = value.Minutes + " Minutes, " + value.Seconds + " Seconds";
    else if (value.TotalDays < 1)
        duration = value.Hours + " Hours, " + value.Minutes + " Minutes";
    else
        duration = value.Days + " Days, " + value.Hours + " Hours";

    if (duration.StartsWith("1 Seconds") || duration.EndsWith(" 1 Seconds"))
        duration = duration.Replace("1 Seconds", "1 Second");

    if (duration.StartsWith("1 Minutes") || duration.EndsWith(" 1 Minutes"))
        duration = duration.Replace("1 Minutes", "1 Minute");

    if (duration.StartsWith("1 Hours") || duration.EndsWith(" 1 Hours"))
        duration = duration.Replace("1 Hours", "1 Hour");

    if (duration.StartsWith("1 Days"))
        duration = duration.Replace("1 Days", "1 Day");

    return duration;
}

Upvotes: 36

Views: 19997

Answers (13)

TheBigNeo
TheBigNeo

Reputation: 181

We have created an implementation of it, which also displays years, months and weeks.
A maximum of 2 units are displayed.
Since the number of days in a month are not always the same, there may be inaccuracies.

Examples:

  • 1 day
  • 2 weeks
  • 1 week 3 days
  • 1 year 1 month
  • 1 year 1 week
  • 1 year 1 day
  • ...

Code:


/// <summary>
///     Formats a <see cref="TimeSpan" /> to a human-readable string.
/// </summary>
public static class TimeSpanHumanReadable
{
    /// <summary>
    ///     Formats the given <paramref name="timeSpan" /> to a human-readable format.
    /// </summary>
    /// <remarks>
    ///     This method intelligently formats the given <paramref name="timeSpan" /> to produce a result
    ///     that combines years, months, weeks, days, hours, minutes, seconds, and milliseconds, ensuring
    ///     the output is both accurate and intuitive for a wide range of durations.<br />
    /// </remarks>
    /// <param name="timeSpan">The value to format.</param>
    /// <returns>A human-readable <see cref="TimeSpan" />. (See Example)</returns>
    /// <example>
    ///     Example outputs for various <paramref name="timeSpan" /> values:
    ///     <ul>
    ///         <li>1 day</li>
    ///         <li>2 weeks</li>
    ///         <li>1 week 3 days</li>
    ///         <li>1 year 1 month</li>
    ///         <li>1 year 1 week</li>
    ///         <li>1 year 1 day</li>
    ///         <li>1 hour</li>
    ///         <li>1 hour 15 min</li>
    ///         <li>1 min 15 sec</li>
    ///         <li><c>TimeSpan.FromDays(400)</c>: 1 year 1 month</li>
    ///         <li><c>TimeSpan.FromHours(30)</c>: 1 day 6 hours</li>
    ///         <li><c>TimeSpan.FromMilliseconds(12345)</c>: 12 sec</li>
    ///         <li><c>TimeSpan.FromMinutes(90)</c>: 1 hour 30 min</li>
    ///     </ul>
    /// </example>
    [Pure]
    public static string ToHumanReadableString(this TimeSpan timeSpan)
    {
        TimeValueClass timeValue = new(timeSpan);
        StringBuilder  builder   = new();

        ProcessTimeValue(builder, timeValue);

        return builder.ToString();
    }

    // ReSharper disable once CognitiveComplexity
    private static void ProcessTimeValue(this StringBuilder builder, TimeValueClass timeValue)
    {
        if (timeValue.Years is not 0)
        {
            // 1 year
            builder.AddYears(timeValue);

            if (timeValue.Months is not 0)
            {
                // 1 year 1 month
                builder.AddSpace()
                       .AddMonths(timeValue);
            }
            else if (timeValue.Weeks is not 0)
            {
                // 1 year 1 week
                builder.AddSpace()
                       .AddWeeks(timeValue);
            }
            else if (timeValue.Days is not 0)
            {
                // 1 year 1 day
                builder.AddSpace()
                       .AddDays(timeValue);
            }

            return;
        }

        if (timeValue.Months is not 0)
        {
            // 1 month
            builder.AddMonths(timeValue);

            if (timeValue.Weeks is not 0)
            {
                // 1 month 1 week
                builder.AddSpace()
                       .AddWeeks(timeValue);
            }
            else if (timeValue.Days is >= 3 and <= 6)
            {
                // 1 month 3 days
                builder.AddSpace()
                       .AddDays(timeValue);
            }

            return;
        }

        if (timeValue.Weeks is not 0)
        {
            builder.AddWeeks(timeValue);

            if (timeValue.Days is not 0)
            {
                builder.AddSpace()
                       .AddDays(timeValue);
            }

            return;
        }

        if (timeValue.Days is not 0)
        {
            builder.AddDays(timeValue);

            if (timeValue.Hours is not 0)
            {
                builder.AddSpace()
                       .AddHours(timeValue);
            }

            return;
        }

        if (timeValue.Hours is not 0)
        {
            builder.AddHours(timeValue);

            if (timeValue.Minutes is not 0)
            {
                builder.AddSpace()
                       .AddMinutes(timeValue);
            }

            return;
        }

        if (timeValue.Minutes is not 0)
        {
            builder.AddMinutes(timeValue);

            if (timeValue.Seconds is not 0)
            {
                builder.AddSpace()
                       .AddSeconds(timeValue);
            }

            return;
        }

        if (timeValue.Seconds is not 0)
        {
            builder.AddSeconds(timeValue);
            return;
        }

        if (timeValue.Milliseconds is not 0)
        {
            builder.AddMilliseconds(timeValue);
            return;
        }

        builder.Append("000 ms");
    }

    private static StringBuilder AddSpace(this StringBuilder builder)
    {
        return builder.Append(' ');
    }

    private static StringBuilder AddYears(this StringBuilder builder, TimeValueClass timeValue)
        => builder.AppendValueAndUnit(timeValue.Years, " year", " years");

    private static StringBuilder AddMonths(this StringBuilder builder, TimeValueClass timeValue)
        => builder.AppendValueAndUnit(timeValue.Months, " month", " months");

    private static StringBuilder AddWeeks(this StringBuilder builder, TimeValueClass timeValue)
        => builder.AppendValueAndUnit(timeValue.Weeks, " week", " weeks");

    private static StringBuilder AddDays(this StringBuilder builder, TimeValueClass timeValue)
        => builder.AppendValueAndUnit(timeValue.Days, " day", " days");

    private static StringBuilder AddHours(this StringBuilder builder, TimeValueClass timeValue)
        => builder.AppendValueAndUnit(timeValue.Hours, " hour", " hours");

    private static StringBuilder AppendValueAndUnit(this StringBuilder builder, int value, string singular, string plural)
    {
        return builder.Append(value)
                      .Append(value is 1 or -1 ? singular : plural);
    }

    private static StringBuilder AddMinutes(this StringBuilder builder, TimeValueClass timeValue)
        => builder.AppendValueAndUnit(timeValue.Minutes, " min");

    private static StringBuilder AddSeconds(this StringBuilder builder, TimeValueClass timeValue)
        => builder.AppendValueAndUnit(timeValue.Seconds, " sec");

    private static StringBuilder AppendValueAndUnit(this StringBuilder builder, int value, string unit)
    {
        return builder.Append(value)
                      .Append(unit);
    }

    private static StringBuilder AddMilliseconds(this StringBuilder builder, TimeValueClass timeValue)
    {
        // We show ms with leading zeros. So we have to add the '-' here.
        if (timeValue.Milliseconds < 0)
        {
            builder.Append('-');
        }

        builder.Append(Math.Abs(timeValue.Milliseconds).ToString().PadLeft(3, '0'));
        builder.Append(" ms");
        return builder;
    }

    /// <remarks>
    ///     With help from https://stackoverflow.com/a/21260317/1847143
    /// </remarks>
    private class TimeValueClass
    {
        private const double DaysPerMonth = 30.4375; // Average days per month.
        private const double DaysPerWeek  = 7;
        private const double DaysPerYear  = 365;

        public int Days         { get; }
        public int Hours        { get; }
        public int Milliseconds { get; }
        public int Minutes      { get; }
        public int Months       { get; }
        public int Seconds      { get; }
        public int Weeks        { get; }

        public int Years { get; }

        public TimeValueClass(TimeSpan timeSpan)
        {
            // Calculate the span in days
            int days = timeSpan.Days;

            // 362 days == 11 months and 4 weeks. 4 weeks => 1 month and 12 months => 1 year. So we have to exclude this value
            bool has362Days = days % 362 == 0;

            // Calculate years
            int years = (int)(days / DaysPerYear);

            // Decrease the remaining days
            days -= (int)(years * DaysPerYear);

            // Calculate months
            int months = (int)(days / DaysPerMonth);

            // Decrease the remaining days
            days -= (int)(months * DaysPerMonth);

            // Calculate weeks
            int weeks = (int)(days / DaysPerWeek);

            // Decrease the remaining days
            days -= (int)(weeks * DaysPerWeek);

            // 4 weeks is 1 month
            if (weeks is 4 && has362Days is false)
            {
                weeks = 0;
                months++;
            }
            else if (weeks is -4 && has362Days is false)
            {
                weeks = 0;
                months--;
            }

            // 12 months is 1 year
            if (months == 12)
            {
                months = 0;
                years++;
            }

            Years        = years;
            Months       = months;
            Weeks        = weeks;
            Days         = days;
            Hours        = timeSpan.Hours;
            Minutes      = timeSpan.Minutes;
            Seconds      = timeSpan.Seconds;
            Milliseconds = timeSpan.Milliseconds;
        }
    }
}

Unit Tests:



/// <summary>
///     Test class for <see cref="Utils.Data.TimeSpanHumanReadable.ToHumanReadableString" />
/// </summary>
[NoReorder]
[TestFixture]
public class TimeSpanHumanReadableTests : AbstractTestBase
{
    [TestCase(-1, "-1 day")]
    [TestCase(1,  "1 day")]
    [TestCase(2,  "2 days")]
    [TestCase(3,  "3 days")]
    [TestCase(4,  "4 days")]
    [TestCase(5,  "5 days")]
    [TestCase(6,  "6 days")]
    [TestCase(7,  "1 week")]
    [TestCase(-7, "-1 week")]
    public void ToHumanReadableString_DayValues_ReturnsHumanReadableString(int days, string expected)
    {
        TimeSpan timeSpan = new(days, 0, 0, 0, 0);
        Assert.That(TimeSpanHumanReadable.ToHumanReadableString(timeSpan), Is.EqualTo(expected), timeSpan.ToString());
    }

    [TestCase(28,  "1 month")]
    [TestCase(-28, "-1 month")]
    [TestCase(29,  "1 month")]
    [TestCase(-29, "-1 month")]
    [TestCase(30,  "1 month")]
    [TestCase(-30, "-1 month")]
    [TestCase(31,  "1 month")]
    [TestCase(-31, "-1 month")]
    [TestCase(32,  "1 month")]
    [TestCase(-32, "-1 month")]
    public void ToHumanReadableString_DaysFor1Month_ReturnsHumanReadableString(int days, string expected)
    {
        TimeSpan timeSpan = new(days, 0, 0, 0, 0);
        Assert.That(TimeSpanHumanReadable.ToHumanReadableString(timeSpan), Is.EqualTo(expected), timeSpan.ToString());
    }

    [TestCase(-58, "-2 months")]
    [TestCase(58,  "2 months")]
    [TestCase(59,  "2 months")]
    [TestCase(60,  "2 months")]
    [TestCase(61,  "2 months")]
    [TestCase(62,  "2 months")]
    public void ToHumanReadableString_DaysFor2Months_ReturnsHumanReadableString(int days, string expected)
    {
        TimeSpan timeSpan = new(days, 0, 0, 0, 0);
        Assert.That(TimeSpanHumanReadable.ToHumanReadableString(timeSpan), Is.EqualTo(expected), timeSpan.ToString());
    }

    [TestCase(-8, "-1 week -1 day")]
    [TestCase(8,  "1 week 1 day")]
    [TestCase(16, "2 weeks 2 days")]
    public void ToHumanReadableString_DaysForWeeks_ReturnsHumanReadableString(int days, string expected)
    {
        TimeSpan timeSpan = new(days, 0, 0, 0, 0);
        Assert.That(TimeSpanHumanReadable.ToHumanReadableString(timeSpan), Is.EqualTo(expected), timeSpan.ToString());
    }

    [TestCase(30,         "1 month")]
    [TestCase(30 + 1,     "1 month")]
    [TestCase(30 + 2,     "1 month")]
    [TestCase(30 + 3,     "1 month 3 days")]
    [TestCase(30 + 4,     "1 month 4 days")]
    [TestCase(30 + 5,     "1 month 5 days")]
    [TestCase(30 + 6,     "1 month 6 days")]
    [TestCase(30 + 7,     "1 month 1 week")]
    [TestCase(30 + 7 + 1, "1 month 1 week")]
    [TestCase(32 + 7 + 2, "1 month 1 week")]
    [TestCase(32 + 7 + 3, "1 month 1 week")]
    public void ToHumanReadableString_DaysForMonths_ReturnsHumanReadableString(int days, string expected)
    {
        TimeSpan timeSpan = new(days, 0, 0, 0, 0);
        Assert.That(TimeSpanHumanReadable.ToHumanReadableString(timeSpan), Is.EqualTo(expected), timeSpan.ToString());
    }

    [TestCase(365,          "1 year")]
    [TestCase(365  + 1,     "1 year 1 day")]
    [TestCase(365  + 2,     "1 year 2 days")]
    [TestCase(365  + 3,     "1 year 3 days")]
    [TestCase(365  + 4,     "1 year 4 days")]
    [TestCase(365  + 5,     "1 year 5 days")]
    [TestCase(365  + 6,     "1 year 6 days")]
    [TestCase(365  + 7,     "1 year 1 week")]
    [TestCase(365  + 7 + 1, "1 year 1 week")]
    [TestCase(365  + 7 + 2, "1 year 1 week")]
    [TestCase(365  + 7 + 3, "1 year 1 week")]
    [TestCase(365  + 7 + 4, "1 year 1 week")]
    [TestCase(365  + 7 + 5, "1 year 1 week")]
    [TestCase(365  + 7 + 6, "1 year 1 week")]
    [TestCase(365  + 14,    "1 year 2 weeks")]
    [TestCase(365  + 30,    "1 year 1 month")]
    [TestCase(-365 - 30,    "-1 year -1 month")]
    [TestCase(365  + 60,    "1 year 2 months")]
    [TestCase(-365 - 60,    "-1 year -2 months")]
    public void ToHumanReadableString_DaysForYears_ReturnsHumanReadableString(int days, string expected)
    {
        TimeSpan timeSpan = new(days, 0, 0, 0, 0);
        Assert.That(TimeSpanHumanReadable.ToHumanReadableString(timeSpan), Is.EqualTo(expected), timeSpan.ToString());
    }

    [TestCase(1,    0,   0,   0,   0,     "1 day")]
    [TestCase(-1,   0,   0,   0,   0,     "-1 day")]
    [TestCase(0,    1,   0,   0,   0,     "1 hour")]
    [TestCase(0,    -1,  0,   0,   0,     "-1 hour")]
    [TestCase(0,    0,   1,   0,   0,     "1 min")]
    [TestCase(0,    0,   -1,  0,   0,     "-1 min")]
    [TestCase(0,    0,   0,   1,   0,     "1 sec")]
    [TestCase(0,    0,   0,   -1,  0,     "-1 sec")]
    [TestCase(0,    0,   0,   0,   1,     "001 ms")]
    [TestCase(0,    0,   0,   0,   -1,    "-001 ms")]
    [TestCase(0,    15,  0,   0,   0,     "15 hours")]
    [TestCase(0,    -15, 0,   0,   0,     "-15 hours")]
    [TestCase(0,    0,   15,  0,   0,     "15 min")]
    [TestCase(0,    0,   -15, 0,   0,     "-15 min")]
    [TestCase(0,    0,   0,   15,  0,     "15 sec")]
    [TestCase(0,    0,   0,   -15, 0,     "-15 sec")]
    [TestCase(0,    0,   0,   0,   15,    "015 ms")]
    [TestCase(0,    0,   0,   0,   -15,   "-015 ms")]
    [TestCase(1,    1,   0,   0,   0,     "1 day 1 hour")]
    [TestCase(2,    2,   0,   0,   0,     "2 days 2 hours")]
    [TestCase(5,    5,   5,   5,   5,     "5 days 5 hours")]
    [TestCase(0,    1,   1,   0,   0,     "1 hour 1 min")]
    [TestCase(0,    2,   2,   0,   0,     "2 hours 2 min")]
    [TestCase(0,    0,   1,   1,   0,     "1 min 1 sec")]
    [TestCase(0,    0,   2,   2,   0,     "2 min 2 sec")]
    [TestCase(0,    0,   0,   1,   1,     "1 sec")]  // With ms
    [TestCase(0,    0,   0,   2,   2,     "2 sec")]  // With ms
    [TestCase(0,    0,   0,   0,   0,     "000 ms")] // With ms
    [TestCase(400,  0,   0,   0,   0,     "1 year 1 month")]
    [TestCase(0,    30,  0,   0,   0,     "1 day 6 hours")]
    [TestCase(0,    0,   0,   0,   12345, "12 sec")]
    [TestCase(0,    0,   90,  0,   0,     "1 hour 30 min")]
    [TestCase(5000, 0,   90,  0,   0,     "13 years 8 months")]
    public void ToHumanReadableString_TimeValues_ReturnsHumanReadableString(int days, int hours, int minutes, int seconds, int milliseconds, string expected)
    {
        TimeSpan timeSpan = new(days, hours, minutes, seconds, milliseconds);
        Assert.That(TimeSpanHumanReadable.ToHumanReadableString(timeSpan), Is.EqualTo(expected), timeSpan.ToString());
    }
}

Upvotes: 3

tmaj
tmaj

Reputation: 34947

Here's yet another option.

Test cases:

var testCases = new List<HumanReadableTimeStringTestCase>
{
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "1.88s",
        ExpectedLong = "1.88 seconds",
        Span = TimeSpan.FromMilliseconds(1880)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "90s",
        ExpectedLong = "90 seconds",
        Span = TimeSpan.FromSeconds(90.4)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "90s",  // No rounding for seconds
        ExpectedLong = "90 seconds",
        Span = TimeSpan.FromSeconds(90.7)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "90m",
        ExpectedLong = "90 minutes",
        Span = TimeSpan.FromMinutes(90.4)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "119m",
        ExpectedLong = "119 minutes",
        Span = TimeSpan.FromMinutes(119.4)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "2h",
        ExpectedLong = "2 hours, 0 minutes",
        Span = TimeSpan.FromMinutes(120)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "3h", // rounded
        ExpectedLong = "2 hours, 55 minutes",
        Span = TimeSpan.FromMinutes(120 + 55)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "24h",
        ExpectedLong = "24 hours, 3 minutes",
        Span = new TimeSpan(days: 1, hours: 0, minutes: 3, seconds: 0)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "26h",
        ExpectedLong = "26 hours, 3 minutes",
        Span = new TimeSpan(days: 1, hours: 2, minutes: 3, seconds: 0)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "27h",
        ExpectedLong = "26 hours, 31 minutes",
        Span = new TimeSpan(days: 1, hours: 2, minutes: 31, seconds: 0)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "24h",
        ExpectedLong = "24 hours, 3 minutes",
        Span = new TimeSpan(days: 1, hours: 0, minutes: 3, seconds: 0)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "2d,0h",
        ExpectedLong = "2 days, 0 hours",
        Span = new TimeSpan(days: 2, hours: 0, minutes: 3, seconds: 0)
    },
    new HumanReadableTimeStringTestCase
    {
        ExpectedShort = "2d,4h",
        ExpectedLong = "2 days, 4 hours",
        Span = new TimeSpan(days: 2, hours: 4, minutes: 3, seconds: 0)
    },
};

Code:

public static string ToHumanReadableString(TimeSpan t)
{
    if (t.TotalSeconds < 2)
    {
        return $@"{t.TotalSeconds:.##} seconds";
    }

    if (t.TotalMinutes < 2)
    {
        return $@"{(int)t.TotalSeconds} seconds";
    }

    if (t.TotalHours < 2)
    {
        return $@"{(int)Math.Round(t.TotalMinutes, MidpointRounding.AwayFromZero)} minutes";
    }

    if (t.TotalDays < 2)
    {
        return $@"{(int)(t.TotalMinutes / 60)} hours, {t:%m} minutes";
    }

    return $@"{t:%d} days, {t:%h} hours";
}

public static string ToHumanReadableStringShort(TimeSpan t)
{
    if (t.TotalSeconds < 2)
    {
        return $@"{t.TotalSeconds:0.##}s";
    }

    if (t.TotalMinutes < 2)
    {
        return $@"{(int)t.TotalSeconds}s";
    }

    if (t.TotalHours < 2)
    {
        return $@"{(int)Math.Round(t.TotalMinutes, MidpointRounding.AwayFromZero)}m";
    }

    if (t.TotalDays < 2)
    {
        return $@"{(int)Math.Round(t.TotalHours, MidpointRounding.AwayFromZero)}h";
    }

    return $@"{t:%d}d,{t:%h}h";
}

Upvotes: 0

Jarvis
Jarvis

Reputation: 691

I prefer to throw away the details -- for instance, once you're up to a number of months, then the number of seconds is irrelevant. As such, I use natural switching points to reach the next level, and I throw away all decimal values. Of course, this also eliminates the singular/plural problem entirely.

    private static string LastFetched(TimeSpan ago)
    {
        string lastFetched = "last fetched ";
        if (ago.TotalDays >= 90)
            lastFetched += $"{(int)ago.TotalDays / 30} months ago";
        else if (ago.TotalDays >= 14)
            lastFetched += $"{(int)ago.TotalDays / 7} weeks ago";
        else if (ago.TotalDays >= 2)
            lastFetched += $"{(int)ago.TotalDays} days ago";
        else if (ago.TotalHours >= 2)
            lastFetched += $"{(int)ago.TotalHours} hours ago";
        else if (ago.TotalMinutes >= 2)
            lastFetched += $"{(int)ago.TotalMinutes} minutes ago";
        else if (ago.TotalSeconds >= 10)
            lastFetched += $"{(int)ago.TotalSeconds} seconds ago";
        else
            lastFetched += $"just now";

        return lastFetched;
    }

Upvotes: 1

Dave Michener
Dave Michener

Reputation: 1098

Reviving an old post, but...

Try the Humanizer library which can do this very easily:

TimeSpan.FromMilliseconds(1).Humanize() => "1 millisecond"
TimeSpan.FromMilliseconds(2).Humanize() => "2 milliseconds"
TimeSpan.FromDays(1).Humanize() => "1 day"
TimeSpan.FromDays(16).Humanize() => "2 weeks"

By default it gives you rounded whole value of the time span. But you can also ask for a more precision:

TimeSpan.FromDays(16).Humanize(2) => "2 weeks, 2 days"

Upvotes: 12

rene
rene

Reputation: 42414

To get rid of the complex if and switch constructs you can use a Dictionary lookup for the correct format string based on TotalSeconds and a CustomFormatter to format the supplied Timespan accordingly.

public string GetReadableTimespan(TimeSpan ts)
{
     // formats and its cutoffs based on totalseconds
     var cutoff = new SortedList<long, string> { 
       {59, "{3:S}" }, 
       {60, "{2:M}" },
       {60*60-1, "{2:M}, {3:S}"},
       {60*60, "{1:H}"},
       {24*60*60-1, "{1:H}, {2:M}"},
       {24*60*60, "{0:D}"},
       {Int64.MaxValue , "{0:D}, {1:H}"}
     };

     // find nearest best match
     var find = cutoff.Keys.ToList()
                   .BinarySearch((long)ts.TotalSeconds);
     // negative values indicate a nearest match
     var near = find<0?Math.Abs(find)-1:find;
     // use custom formatter to get the string
     return String.Format(
         new HMSFormatter(), 
         cutoff[cutoff.Keys[near]], 
         ts.Days, 
         ts.Hours, 
         ts.Minutes, 
         ts.Seconds);
}

// formatter for forms of
// seconds/hours/day
public class HMSFormatter:ICustomFormatter, IFormatProvider
{
    // list of Formats, with a P customformat for pluralization
    static Dictionary<string, string> timeformats = new Dictionary<string, string> {
        {"S", "{0:P:Seconds:Second}"},
        {"M", "{0:P:Minutes:Minute}"},
        {"H","{0:P:Hours:Hour}"},
        {"D", "{0:P:Days:Day}"}
    };

    public string Format(string format, object arg, IFormatProvider formatProvider)
    {
        return String.Format(new PluralFormatter(),timeformats[format], arg);
    }

    public object GetFormat(Type formatType)
    {
        return formatType == typeof(ICustomFormatter)?this:null;
    }   
}

// formats a numeric value based on a format P:Plural:Singular
public class PluralFormatter:ICustomFormatter, IFormatProvider
{

   public string Format(string format, object arg, IFormatProvider formatProvider)
   {
     if (arg !=null)
     {
         var parts = format.Split(':'); // ["P", "Plural", "Singular"]

         if (parts[0] == "P") // correct format?
         {
            // which index postion to use
            int partIndex = (arg.ToString() == "1")?2:1;
            // pick string (safe guard for array bounds) and format
            return String.Format("{0} {1}", arg, (parts.Length>partIndex?parts[partIndex]:""));               
         }
     }
     return String.Format(format, arg);
   }

   public object GetFormat(Type formatType)
   {
       return formatType == typeof(ICustomFormatter)?this:null;
   }   
}

Upvotes: 36

spujia
spujia

Reputation: 147

Here's mine, very simple -

TimeSpan timeElapsed = DateTime.Now - referenceTime_;
string timeString = "";
if (timeElapsed.Hours > 0)
    timeString = timeElapsed.Hours.ToString() + " hour(s), " + timeElapsed.Minutes.ToString() + " minutes, " + timeElapsed.Seconds.ToString() + " seconds";
else if (timeElapsed.Minutes > 0)
    timeString = timeElapsed.Minutes.ToString() + " minutes, " + timeElapsed.Seconds.ToString() + " seconds";
else
    timeString = timeElapsed.Seconds.ToString() + " seconds";

Upvotes: 1

PaulCollingwood
PaulCollingwood

Reputation: 1

Another stab at this. Deals with the pluralising of units (and omitting zero units) more coherently:

private string GetValueWithPluralisedUnits(int value, string units, int prefix_value)
    {
        if (value != 0)
        {
            return (prefix_value == 0 ? "" : ", ") + value.ToString() + " " + units + (value == 1 ? "" : "s");
        }

        return "";
    }

    private string GetReadableTimeSpan(TimeSpan value)
    {
        string duration;

        if (value.TotalMinutes < 1)
        {
            if (value.Seconds > 0)
            {
                duration = GetValueWithPluralisedUnits(value.Seconds, "Second", 0);
            }
            else
            {
                duration = "";
            }
        }
        else if (value.TotalHours < 1)
        {
            duration = GetValueWithPluralisedUnits(value.Minutes, "Minute", 0) + GetValueWithPluralisedUnits(value.Seconds, "Second", value.Minutes);
        }
        else if (value.TotalDays < 1)
        {
            duration = GetValueWithPluralisedUnits(value.Hours, "Hour", 0) + GetValueWithPluralisedUnits(value.Minutes, "Minute", value.Hours);
        }
        else
        {
            int days_left = (int)value.TotalDays;
            int years = days_left / 365;
            days_left -= years * 365;
            int months = days_left / 12;
            days_left -= months * 12;

            duration = GetValueWithPluralisedUnits(years, "Year", 0) + GetValueWithPluralisedUnits(months, "Month", years) + GetValueWithPluralisedUnits(days_left, "Day", years + months);
        }

        return duration;
    }

Upvotes: 0

Bohdan Zhmud
Bohdan Zhmud

Reputation: 51

public string ToHumanDuration(TimeSpan? duration, bool displaySign = true)
    {
        if (duration == null) return null;

        var builder = new StringBuilder();
        if (displaySign)
        {
            builder.Append(duration.Value.TotalMilliseconds < 0 ? "-" : "+");
        }

        duration = duration.Value.Duration();

        if (duration.Value.Days > 0)
        {
            builder.Append($"{duration.Value.Days}d ");
        }

        if (duration.Value.Hours > 0)
        {
            builder.Append($"{duration.Value.Hours}h ");
        }

        if (duration.Value.Minutes > 0)
        {
            builder.Append($"{duration.Value.Minutes}m ");
        }

        if (duration.Value.TotalHours < 1)
        {
            if (duration.Value.Seconds > 0)
            {
                builder.Append(duration.Value.Seconds);
                if (duration.Value.Milliseconds > 0)
                {
                    builder.Append($".{duration.Value.Milliseconds.ToString().PadLeft(3, '0')}");
                }

                builder.Append("s ");
            }
            else
            {
                if (duration.Value.Milliseconds > 0)
                {
                    builder.Append($"{duration.Value.Milliseconds}ms ");
                }
            }
        }

        if (builder.Length <= 1)
        {
            builder.Append(" <1ms ");
        }

        builder.Remove(builder.Length - 1, 1);

        return builder.ToString();
    }

Source: https://github.com/HangfireIO/Hangfire/blob/master/src/Hangfire.Core/Dashboard/HtmlHelper.cs

Upvotes: 5

user2676274
user2676274

Reputation: 101

I built upon Bjorn's answer to fit my needs, wanted to share in case anyone else saw this issue. May save them time. The accepted answer is a bit heavyweight for my needs.

    private static string FormatTimeSpan(TimeSpan timeSpan)
    {
        Func<Tuple<int,string>, string> tupleFormatter = t => $"{t.Item1} {t.Item2}{(t.Item1 == 1 ? string.Empty : "s")}";
        var components = new List<Tuple<int, string>>
        {
            Tuple.Create((int) timeSpan.TotalDays, "day"),
            Tuple.Create(timeSpan.Hours, "hour"),
            Tuple.Create(timeSpan.Minutes, "minute"),
            Tuple.Create(timeSpan.Seconds, "second"),
        };

        components.RemoveAll(i => i.Item1 == 0);

        string extra = "";

        if (components.Count > 1)
        {
            var finalComponent = components[components.Count - 1];
            components.RemoveAt(components.Count - 1);
            extra = $" and {tupleFormatter(finalComponent)}";
        }

        return $"{string.Join(", ", components.Select(tupleFormatter))}{extra}";
    }

Upvotes: 10

Jonathan ANTOINE
Jonathan ANTOINE

Reputation: 9213

I would have prefered something like this which is more "readable" I think :

public string GetReadableTimeSpan(TimeSpan value)
{
 string duration = "";

 var totalDays = (int)value.TotalDays;
 if (totalDays >= 1)
 {
     duration = totalDays + " day" + (totalDays > 1 ? "s" : string.Empty);
     value = value.Add(TimeSpan.FromDays(-1 * totalDays));
 }

 var totalHours = (int)value.TotalHours;
 if (totalHours >= 1)
 {
     if (totalDays >= 1)
     {
         duration += ", ";
     }
     duration += totalHours + " hour" + (totalHours > 1 ? "s" : string.Empty);
     value = value.Add(TimeSpan.FromHours(-1 * totalHours));
 }

 var totalMinutes = (int)value.TotalMinutes;
 if (totalMinutes >= 1)
 {
     if (totalHours >= 1)
     {
         duration += ", ";
     }
     duration += totalMinutes + " minute" + (totalMinutes > 1 ? "s" : string.Empty);
 }

 return duration;
}

Upvotes: 2

user3638471
user3638471

Reputation:

Here's my take - a bit simpler than the accepted answer, don't you think? Also, no string splitting/parsing.

var components = new List<Tuple<int, string>> {
    Tuple.Create((int)span.TotalDays, "day"),
    Tuple.Create(span.Hours, "hour"),
    Tuple.Create(span.Minutes, "minute"),
    Tuple.Create(span.Seconds, "second"),
};

while(components.Any() && components[0].Item1 == 0)
{
    components.RemoveAt(0);
}

var result = string.Join(", ", components.Select(t => t.Item1 + " " + t.Item2 + (t.Item1 != 1 ? "s" : string.Empty)));

Upvotes: 3

mafu
mafu

Reputation: 32640

Why not simply something like this?

public static class TimespanExtensions
{
    public static string ToHumanReadableString (this TimeSpan t)
    {
        if (t.TotalSeconds <= 1) {
            return $@"{t:s\.ff} seconds";
        }
        if (t.TotalMinutes <= 1) {
            return $@"{t:%s} seconds";
        }
        if (t.TotalHours <= 1) {
            return $@"{t:%m} minutes";
        }
        if (t.TotalDays <= 1) {
            return $@"{t:%h} hours";
        }

        return $@"{t:%d} days";
    }
}

If you prefer two units of time (e.g. minutes plus seconds), that would be very simple to add.

Upvotes: 21

Jonas_Hess
Jonas_Hess

Reputation: 2008

Another approach (In German language)

public static string GetReadableTimeSpan(TimeSpan span)
{
    var formatted = string.Format("{0}{1}{2}{3}",
        span.Duration().Days > 0
            ? $"{span.Days:0} Tag{(span.Days == 1 ? string.Empty : "e")}, "
            : string.Empty,
        span.Duration().Hours > 0
            ? $"{span.Hours:0} Stunde{(span.Hours == 1 ? string.Empty : "n")}, "
            : string.Empty,
        span.Duration().Minutes > 0
            ? $"{span.Minutes:0} Minute{(span.Minutes == 1 ? string.Empty : "n")}, "
            : string.Empty,
        span.Duration().Seconds > 0
            ? $"{span.Seconds:0} Sekunde{(span.Seconds == 1 ? string.Empty : "n")}"
            : string.Empty);

    if (formatted.EndsWith(", ")) formatted = formatted.Substring(0, formatted.Length - 2);

    return string.IsNullOrEmpty(formatted) ? "0 Sekunden" : ReplaceLastOccurrence(formatted, ",", " und ").Replace("  ", " ");
}

private static string ReplaceLastOccurrence(string source, string find, string replace)
{
    var place = source.LastIndexOf(find, StringComparison.Ordinal);

    if (place == -1)
        return source;

    var result = source.Remove(place, find.Length).Insert(place, replace);
    return result;
}

Upvotes: 1

Related Questions