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>
/// Format a <see cref="TimeSpan" /> to a human readable string.
/// </summary>
[NoReorder]
public static class TimeSpanHumanReadable
{
#pragma warning disable CS8618
private static TimeValueClass TimeValue;
private static StringBuilder DateStringBuilder;
#pragma warning restore CS8618
/// <summary>
/// Format the given <paramref name="timeSpan" /> to a human readable format.
/// </summary>
/// <param name="timeSpan">The value to format</param>
/// <returns>The formatted value</returns>
[Pure]
public static string ToHumanReadableString(this TimeSpan timeSpan)
{
TimeValue = new TimeValueClass(timeSpan);
DateStringBuilder = new StringBuilder();
ProcessTimeValue();
return DateStringBuilder.ToString().Trim();
}
// ReSharper disable once CognitiveComplexity
private static void ProcessTimeValue()
{
if (TimeValue.Years is not 0)
{
// 1 year
AddYears();
AddSpace();
if (TimeValue.Months is not 0)
{
// 1 year 1 month
AddMonths();
}
else if (TimeValue.Weeks is not 0)
{
// 1 year 1 week
AddWeeks();
}
else
{
// 1 year 1 day
AddDays();
}
return;
}
if (TimeValue.Months is not 0)
{
// 1 month
AddMonths();
AddSpace();
if (TimeValue.Weeks is not 0)
{
// 1 month 1 week
AddWeeks();
}
else if (TimeValue.Days is >= 3 and <= 6)
{
// 1 month 1 day
AddDays();
}
return;
}
if (TimeValue.Weeks is not 0)
{
AddWeeks();
AddSpace();
AddDays();
return;
}
if (TimeValue.Days is not 0)
{
AddDays();
AddSpace();
AddHours();
return;
}
if (TimeValue.Hours is not 0)
{
AddHours();
AddSpace();
AddMinutes();
return;
}
if (TimeValue.Minutes is not 0)
{
AddMinutes();
AddSpace();
AddSeconds();
return;
}
if (TimeValue.Seconds is not 0)
{
AddSeconds();
return;
}
if (TimeValue.Milliseconds is not 0)
{
AddMilliseconds();
return;
}
DateStringBuilder.Append("000 ms");
}
private static void AddSpace()
{
DateStringBuilder.Append(' ');
}
private static void AddYears()
{
if (TimeValue.Years is 0)
{
return;
}
DateStringBuilder.Append(TimeValue.Years);
DateStringBuilder.Append(TimeValue.Years is 1 ? " year" : " years");
}
private static void AddMonths()
{
if (TimeValue.Months is 0)
{
return;
}
DateStringBuilder.Append(TimeValue.Months);
DateStringBuilder.Append(TimeValue.Months is 1 ? " month" : " months");
}
private static void AddWeeks()
{
if (TimeValue.Weeks is 0)
{
return;
}
DateStringBuilder.Append(TimeValue.Weeks);
DateStringBuilder.Append(TimeValue.Weeks is 1 ? " week" : " weeks");
}
private static void AddDays()
{
if (TimeValue.Days is 0)
{
return;
}
DateStringBuilder.Append(TimeValue.Days);
DateStringBuilder.Append(TimeValue.Days is 1 ? " day" : " days");
}
private static void AddHours()
{
if (TimeValue.Hours is 0)
{
return;
}
DateStringBuilder.Append(TimeValue.Hours);
DateStringBuilder.Append(TimeValue.Hours is 1 ? " hour" : " hours");
}
private static void AddMinutes()
{
if (TimeValue.Minutes is 0)
{
return;
}
DateStringBuilder.Append(TimeValue.Minutes);
DateStringBuilder.Append(" min");
}
private static void AddSeconds()
{
if (TimeValue.Seconds is 0)
{
return;
}
DateStringBuilder.Append(TimeValue.Seconds);
DateStringBuilder.Append(" sec");
}
private static void AddMilliseconds()
{
if (TimeValue.Milliseconds is 0)
{
return;
}
DateStringBuilder.Append(TimeValue.Milliseconds.ToString().PadLeft(3, '0'));
DateStringBuilder.Append(" ms");
}
/// <remarks>
/// With help from https://mcmap.net/q/428677/-format-a-timespan-with-years
/// </remarks>
private class TimeValueClass
{
private const double DaysPerMonth = 30.4375;
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 remaining days
days -= (int)(years * DaysPerYear);
// Calculate months
int months = (int)(days / DaysPerMonth);
// Decrease remaining days
days -= (int)(months * DaysPerMonth);
// Calculate weeks
int weeks = (int)(days / DaysPerWeek);
// Decrease remaining days
days -= (int)(weeks * DaysPerWeek);
// 4 weeks is 1 month
if (weeks is 4 && has362Days is false)
{
weeks = 0;
months++;
days -= (int)(weeks * DaysPerMonth);
}
// 12 months is 1 year
if (months == 12)
{
months = 0;
years++;
days -= (int)(months * DaysPerMonth);
}
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 : AbstractTest
{
[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")]
public void ToHumanReadableString_DayValues_ReturnsHumanReadableString(int days, string expected)
{
TimeSpan timeSpan = new(days, 0, 0, 0, 0);
Assert.AreEqual(expected, TimeSpanHumanReadable.ToHumanReadableString(timeSpan), timeSpan.ToString());
}
[TestCase(28, "1 month")]
[TestCase(29, "1 month")]
[TestCase(30, "1 month")]
[TestCase(31, "1 month")]
[TestCase(32, "1 month")]
public void ToHumanReadableString_DaysFor1Month_ReturnsHumanReadableString(int days, string expected)
{
TimeSpan timeSpan = new(days, 0, 0, 0, 0);
Assert.AreEqual(expected, TimeSpanHumanReadable.ToHumanReadableString(timeSpan), timeSpan.ToString());
}
[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.AreEqual(expected, TimeSpanHumanReadable.ToHumanReadableString(timeSpan), timeSpan.ToString());
}
[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.AreEqual(expected, TimeSpanHumanReadable.ToHumanReadableString(timeSpan), 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.AreEqual(expected, TimeSpanHumanReadable.ToHumanReadableString(timeSpan), 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 + 60, "1 year 2 months")]
public void ToHumanReadableString_DaysForYears_ReturnsHumanReadableString(int days, string expected)
{
TimeSpan timeSpan = new(days, 0, 0, 0, 0);
Assert.AreEqual(expected, TimeSpanHumanReadable.ToHumanReadableString(timeSpan), timeSpan.ToString());
}
[TestCase(1, 0, 0, 0, 0, "1 day")]
[TestCase(0, 1, 0, 0, 0, "1 hour")]
[TestCase(0, 0, 1, 0, 0, "1 min")]
[TestCase(0, 0, 0, 1, 0, "1 sec")]
[TestCase(0, 0, 0, 0, 1, "001 ms")]
[TestCase(0, 15, 0, 0, 0, "15 hours")]
[TestCase(0, 0, 15, 0, 0, "15 min")]
[TestCase(0, 0, 0, 15, 0, "15 sec")]
[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
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.AreEqual(expected, TimeSpanHumanReadable.ToHumanReadableString(timeSpan), timeSpan.ToString());
}
}