Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions DesktopClock.Tests/RelativeTimeFormatterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using System;

namespace DesktopClock.Tests;

public class RelativeTimeFormatterTests
{
private static readonly DateTime Now = new(2026, 3, 15, 12, 34, 56, 500);

[Theory]
[InlineData("00:00:00.499", "now")]
[InlineData("00:00:00.500", "now")]
[InlineData("00:00:01", "in a second")]
[InlineData("00:00:59.999", "in 59 seconds")]
[InlineData("00:01:00", "in a minute")]
[InlineData("00:01:59", "in a minute")]
[InlineData("00:02:00", "in 2 minutes")]
[InlineData("00:59:59", "in 59 minutes")]
[InlineData("01:00:00", "in an hour")]
[InlineData("01:29:59", "in an hour")]
[InlineData("01:30:00", "in an hour")]
[InlineData("02:00:00", "in 2 hours")]
[InlineData("23:59:59", "in 23 hours")]
[InlineData("1.00:00:00", "tomorrow")]
[InlineData("1.11:25:03", "tomorrow")]
[InlineData("1.11:25:04", "in 2 days")]
[InlineData("2.00:00:00", "in 2 days")]
[InlineData("27.23:59:59", "in 27 days")]
[InlineData("28.00:00:00", "in 28 days")]
[InlineData("30.00:00:00", "in a month")]
[InlineData("58.23:59:59", "in a month")]
[InlineData("59.00:00:00", "in 2 months")]
[InlineData("344.23:59:59", "in 11 months")]
[InlineData("345.00:00:00", "in a year")]
[InlineData("729.23:59:59", "in a year")]
[InlineData("730.00:00:00", "in 2 years")]
public void Format_PicksLargestUnitForFutureTarget(string offset, string expected)
{
var target = Now + TimeSpan.Parse(offset);

Assert.Equal(expected, RelativeTimeFormatter.Format(target, Now));
}

[Theory]
[InlineData("00:00:01", "a second ago")]
[InlineData("00:00:30", "30 seconds ago")]
[InlineData("00:01:00", "a minute ago")]
[InlineData("00:05:00", "5 minutes ago")]
[InlineData("01:00:00", "an hour ago")]
[InlineData("03:00:00", "3 hours ago")]
[InlineData("1.00:00:00", "yesterday")]
[InlineData("3.00:00:00", "3 days ago")]
[InlineData("30.00:00:00", "a month ago")]
[InlineData("90.00:00:00", "3 months ago")]
[InlineData("365.00:00:00", "a year ago")]
[InlineData("1095.00:00:00", "3 years ago")]
public void Format_PicksLargestUnitForPastTarget(string offset, string expected)
{
var target = Now - TimeSpan.Parse(offset);

Assert.Equal(expected, RelativeTimeFormatter.Format(target, Now));
}

[Fact]
public void Format_JustUnderAMonthIsOneMonthOnlyOnTheSameDate()
{
// February is short enough that 28 days lands on the same date next month.
var febNow = new DateTime(2026, 2, 1, 9, 0, 0);
Assert.Equal("in a month", RelativeTimeFormatter.Format(febNow.AddDays(28), febNow));

// In March the same 28 days fall short of the date next month, so it stays in days, but do reach back to February 1.
var marNow = new DateTime(2026, 3, 1, 9, 0, 0);
Assert.Equal("in 28 days", RelativeTimeFormatter.Format(marNow.AddDays(28), marNow));
Assert.Equal("a month ago", RelativeTimeFormatter.Format(marNow.AddDays(-28), marNow));
}

[Fact]
public void Format_CountsCalendarDaysWithinTwoDays()
{
// 25 hours later is only the next day if it doesn't cross a second midnight.
var lateNow = new DateTime(2026, 3, 15, 23, 30, 0);
Assert.Equal("in 2 days", RelativeTimeFormatter.Format(lateNow.AddHours(25), lateNow));

var earlyNow = new DateTime(2026, 3, 15, 1, 0, 0);
Assert.Equal("tomorrow", RelativeTimeFormatter.Format(earlyNow.AddHours(25), earlyNow));
}

[Fact]
public void Format_IgnoresDateTimeKind()
{
var target = DateTime.SpecifyKind(Now.AddHours(3), DateTimeKind.Utc);
var now = DateTime.SpecifyKind(Now, DateTimeKind.Unspecified);

Assert.Equal("in 3 hours", RelativeTimeFormatter.Format(target, now));
}
}
14 changes: 6 additions & 8 deletions DesktopClock.Tests/TimeStringFormatterTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System;
using System.Globalization;
using Humanizer;

namespace DesktopClock.Tests;

Expand Down Expand Up @@ -82,7 +81,7 @@ public void Format_UsesCountdownFormatWhenProvided()
}

[Fact]
public void Format_UsesHumanizerWhenCountdownFormatMissing()
public void Format_UsesRelativePhraseWhenCountdownFormatMissing()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
Expand All @@ -106,8 +105,7 @@ public void Format_UsesHumanizerWhenCountdownFormatMissing()
" ",
CultureInfo.CurrentCulture);

var localNow = DateTime.SpecifyKind(nowDateTime, DateTimeKind.Local);
Assert.Equal(countdownTo.Humanize(utcDate: false, dateToCompareAgainst: localNow), result);
Assert.Equal("tomorrow", result);
}
finally
{
Expand All @@ -118,14 +116,14 @@ public void Format_UsesHumanizerWhenCountdownFormatMissing()

[Theory]
[InlineData(0, "now")]
[InlineData(3, "3 hours from now")]
[InlineData(3, "in 3 hours")]
[InlineData(-3, "3 hours ago")]
[InlineData(72, "3 days from now")]
[InlineData(72, "in 3 days")]
[InlineData(-72, "3 days ago")]
public void Format_HumanizedCountdownIsRelativeToNow(int hoursFromNow, string expected)
{
// The wall-clock values have Kind Unspecified, which Humanizer used to shift by the
// machine's UTC offset, e.g. a target of "now" read as "6 hours from now" on UTC-6.
// The wall-clock values have Kind Unspecified, which the old Humanizer dependency shifted by
// the machine's UTC offset, e.g. a target of "now" read as "6 hours from now" on UTC-6.
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;

Expand Down
3 changes: 1 addition & 2 deletions DesktopClock/CountdownTargetEditor.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
using System.Windows.Controls;
using System.Windows.Markup;
using DesktopClock.Properties;
using Humanizer;

namespace DesktopClock;

Expand Down Expand Up @@ -103,7 +102,7 @@ private void UpdatePreview()

PreviewText.Text = target == default
? "No target. The clock shows the current time."
: $"{target:f} — {target.Humanize(utcDate: false)}";
: $"{target:f} — {RelativeTimeFormatter.Format(target, DateTime.Now)}";
}

/// <summary>
Expand Down
1 change: 0 additions & 1 deletion DesktopClock/DesktopClock.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Costura.Fody" Version="6.0.0" PrivateAssets="All" />
<PackageReference Include="H.NotifyIcon.Wpf" Version="2.4.1" />
<PackageReference Include="Humanizer.Core" Version="3.0.10" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="PropertyChanged.Fody" Version="4.1.0" PrivateAssets="All" />
<PackageReference Include="WpfWindowPlacement" Version="6.0.0" />
Expand Down
6 changes: 0 additions & 6 deletions DesktopClock/SettingsWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -641,12 +641,6 @@
H.NotifyIcon.Wpf
</Hyperlink>
</TextBlock>
<TextBlock>
<Hyperlink NavigateUri="https://github.com/Humanizr/Humanizer"
RequestNavigate="Hyperlink_RequestNavigate">
Humanizer
</Hyperlink>
</TextBlock>
<TextBlock>
<Hyperlink NavigateUri="https://www.newtonsoft.com/json"
RequestNavigate="Hyperlink_RequestNavigate">
Expand Down
73 changes: 73 additions & 0 deletions DesktopClock/Utilities/RelativeTimeFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using System.Globalization;

namespace DesktopClock;

/// <summary>
/// Describes how far a moment is from now in plain English, such as "in 3 hours" or "yesterday".
/// </summary>
public static class RelativeTimeFormatter
{
/// <summary>
/// Returns a phrase for the distance between <paramref name="target"/> and <paramref name="now"/>, using the single largest unit that fits.
/// </summary>
public static string Format(DateTime target, DateTime now)
{
var isFuture = target > now;
var span = new TimeSpan(Math.Abs(now.Ticks - target.Ticks));

if (span.TotalMilliseconds < 500)
return "now";

if (span.TotalSeconds < 60)
return Phrase(span.Seconds, "a second", "{0} seconds", isFuture);

if (span.TotalSeconds < 120)
return Phrase(1, "a minute", "{0} minutes", isFuture);

if (span.TotalMinutes < 60)
return Phrase(span.Minutes, "a minute", "{0} minutes", isFuture);

if (span.TotalMinutes < 90)
return Phrase(1, "an hour", "{0} hours", isFuture);

if (span.TotalHours < 24)
return Phrase(span.Hours, "an hour", "{0} hours", isFuture);

// Within two days, count calendar dates so late tonight to early the day after tomorrow reads as "2 days".
if (span.TotalHours < 48)
return Days(Math.Abs((target.Date - now.Date).Days), isFuture);

if (span.TotalDays < 28)
return Days(span.Days, isFuture);

// Just under a month reads as one month only when it lands on the same date next (or last) month.
if (span.TotalDays < 30)
{
var sameDateNextMonth = now.Date.AddMonths(isFuture ? 1 : -1) == target.Date;
return sameDateNextMonth ? Phrase(1, "a month", "{0} months", isFuture) : Days(span.Days, isFuture);
}

if (span.TotalDays < 345)
return Phrase((int)Math.Floor(span.TotalDays / 29.5), "a month", "{0} months", isFuture);

var years = Math.Max(1, (int)Math.Floor(span.TotalDays / 365));
return Phrase(years, "a year", "{0} years", isFuture);
}

private static string Days(int days, bool isFuture) => days switch
{
0 => "now",
1 => isFuture ? "tomorrow" : "yesterday",
_ => Phrase(days, "", "{0} days", isFuture),
};

private static string Phrase(int count, string single, string plural, bool isFuture)
{
if (count == 0)
return "now";

var amount = count == 1 ? single : string.Format(CultureInfo.InvariantCulture, plural, count);
return isFuture ? "in " + amount : amount + " ago";
}
}
5 changes: 1 addition & 4 deletions DesktopClock/Utilities/TimeStringFormatter.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System;
using System.Globalization;
using Humanizer;

namespace DesktopClock;

Expand All @@ -24,9 +23,7 @@ public static string Format(
}
else if (string.IsNullOrWhiteSpace(countdownFormat))
{
// Humanizer shifts Unspecified times by the UTC offset when localizing, so pin the Kind to make that conversion a no-op.
var localNow = DateTime.SpecifyKind(nowDateTime, DateTimeKind.Local);
result = countdownTo.Humanize(utcDate: false, dateToCompareAgainst: localNow);
result = RelativeTimeFormatter.Format(countdownTo, nowDateTime);
}
else
{
Expand Down