diff --git a/DesktopClock.Tests/RelativeTimeFormatterTests.cs b/DesktopClock.Tests/RelativeTimeFormatterTests.cs new file mode 100644 index 0000000..20ed00b --- /dev/null +++ b/DesktopClock.Tests/RelativeTimeFormatterTests.cs @@ -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)); + } +} diff --git a/DesktopClock.Tests/TimeStringFormatterTests.cs b/DesktopClock.Tests/TimeStringFormatterTests.cs index 58a8b13..c08fcdf 100644 --- a/DesktopClock.Tests/TimeStringFormatterTests.cs +++ b/DesktopClock.Tests/TimeStringFormatterTests.cs @@ -1,6 +1,5 @@ using System; using System.Globalization; -using Humanizer; namespace DesktopClock.Tests; @@ -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; @@ -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 { @@ -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; diff --git a/DesktopClock/CountdownTargetEditor.xaml.cs b/DesktopClock/CountdownTargetEditor.xaml.cs index fd676ca..f81ef2e 100644 --- a/DesktopClock/CountdownTargetEditor.xaml.cs +++ b/DesktopClock/CountdownTargetEditor.xaml.cs @@ -5,7 +5,6 @@ using System.Windows.Controls; using System.Windows.Markup; using DesktopClock.Properties; -using Humanizer; namespace DesktopClock; @@ -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)}"; } /// diff --git a/DesktopClock/DesktopClock.csproj b/DesktopClock/DesktopClock.csproj index 3b791a7..b71e645 100644 --- a/DesktopClock/DesktopClock.csproj +++ b/DesktopClock/DesktopClock.csproj @@ -20,7 +20,6 @@ - diff --git a/DesktopClock/SettingsWindow.xaml b/DesktopClock/SettingsWindow.xaml index 6071e8d..1fe885c 100644 --- a/DesktopClock/SettingsWindow.xaml +++ b/DesktopClock/SettingsWindow.xaml @@ -641,12 +641,6 @@ H.NotifyIcon.Wpf - - - Humanizer - - diff --git a/DesktopClock/Utilities/RelativeTimeFormatter.cs b/DesktopClock/Utilities/RelativeTimeFormatter.cs new file mode 100644 index 0000000..9f26fcb --- /dev/null +++ b/DesktopClock/Utilities/RelativeTimeFormatter.cs @@ -0,0 +1,73 @@ +using System; +using System.Globalization; + +namespace DesktopClock; + +/// +/// Describes how far a moment is from now in plain English, such as "in 3 hours" or "yesterday". +/// +public static class RelativeTimeFormatter +{ + /// + /// Returns a phrase for the distance between and , using the single largest unit that fits. + /// + 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"; + } +} diff --git a/DesktopClock/Utilities/TimeStringFormatter.cs b/DesktopClock/Utilities/TimeStringFormatter.cs index 2fb63c3..37a2db5 100644 --- a/DesktopClock/Utilities/TimeStringFormatter.cs +++ b/DesktopClock/Utilities/TimeStringFormatter.cs @@ -1,6 +1,5 @@ using System; using System.Globalization; -using Humanizer; namespace DesktopClock; @@ -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 {