From d54aa399ec65474c42f92eb17e2a63f449ec7799 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 21 Sep 2026 14:20:04 -0500 Subject: [PATCH 1/3] Replace Humanizer with a small relative-time formatter Humanizer was only used for the automatic countdown phrase and the countdown target preview, both English-only. RelativeTimeFormatter ports Humanizer 3.0.10's default DateTime algorithm and English strings exactly, verified against the library with a sweep of ~450k offsets across several anchor dates before removing it. Dropping the package removes a 713 KB embedded DLL and takes the Release exe from about 3.6 MB to 2.6 MB. It also removes the Unspecified-Kind workaround from #146 since the port compares ticks directly. --- .../RelativeTimeFormatterTests.cs | 95 +++++++++++++++++++ .../TimeStringFormatterTests.cs | 10 +- DesktopClock/CountdownTargetEditor.xaml.cs | 3 +- DesktopClock/DesktopClock.csproj | 1 - DesktopClock/SettingsWindow.xaml | 6 -- .../Utilities/RelativeTimeFormatter.cs | 74 +++++++++++++++ DesktopClock/Utilities/TimeStringFormatter.cs | 5 +- 7 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 DesktopClock.Tests/RelativeTimeFormatterTests.cs create mode 100644 DesktopClock/Utilities/RelativeTimeFormatter.cs diff --git a/DesktopClock.Tests/RelativeTimeFormatterTests.cs b/DesktopClock.Tests/RelativeTimeFormatterTests.cs new file mode 100644 index 0000000..ab57580 --- /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", "one second from now")] + [InlineData("00:00:59.999", "59 seconds from now")] + [InlineData("00:01:00", "a minute from now")] + [InlineData("00:01:59", "a minute from now")] + [InlineData("00:02:00", "2 minutes from now")] + [InlineData("00:59:59", "59 minutes from now")] + [InlineData("01:00:00", "an hour from now")] + [InlineData("01:29:59", "an hour from now")] + [InlineData("01:30:00", "an hour from now")] + [InlineData("02:00:00", "2 hours from now")] + [InlineData("23:59:59", "23 hours from now")] + [InlineData("1.00:00:00", "tomorrow")] + [InlineData("1.11:25:03", "tomorrow")] + [InlineData("1.11:25:04", "2 days from now")] + [InlineData("2.00:00:00", "2 days from now")] + [InlineData("27.23:59:59", "27 days from now")] + [InlineData("28.00:00:00", "28 days from now")] + [InlineData("30.00:00:00", "one month from now")] + [InlineData("58.23:59:59", "one month from now")] + [InlineData("59.00:00:00", "2 months from now")] + [InlineData("344.23:59:59", "11 months from now")] + [InlineData("345.00:00:00", "one year from now")] + [InlineData("729.23:59:59", "one year from now")] + [InlineData("730.00:00:00", "2 years from now")] + 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", "one 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", "one month ago")] + [InlineData("90.00:00:00", "3 months ago")] + [InlineData("365.00:00:00", "one 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("one month from now", 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("28 days from now", RelativeTimeFormatter.Format(marNow.AddDays(28), marNow)); + Assert.Equal("one 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("2 days from now", 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("3 hours from now", RelativeTimeFormatter.Format(target, now)); + } +} diff --git a/DesktopClock.Tests/TimeStringFormatterTests.cs b/DesktopClock.Tests/TimeStringFormatterTests.cs index 58a8b13..8315c35 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 { @@ -124,8 +122,8 @@ public void Format_UsesHumanizerWhenCountdownFormatMissing() [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..fe010e8 --- /dev/null +++ b/DesktopClock/Utilities/RelativeTimeFormatter.cs @@ -0,0 +1,74 @@ +using System; +using System.Globalization; + +namespace DesktopClock; + +/// +/// Describes how far a moment is from now in plain English, such as "3 hours from now" 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, "one 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, "one month", "{0} months", isFuture) : Days(span.Days, isFuture); + } + + if (span.TotalDays < 345) + return Phrase((int)Math.Floor(span.TotalDays / 29.5), "one month", "{0} months", isFuture); + + var years = Math.Max(1, (int)Math.Floor(span.TotalDays / 365)); + return Phrase(years, "one 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 ? amount + " from now" : 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 { From 7cf25b43272a3ece8c5978737e181bef5c16f709 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 21 Sep 2026 14:20:44 -0500 Subject: [PATCH 2/3] Shorten the countdown phrase: "in 3 days" instead of "3 days from now" Future countdowns now read "in 3 days", "in an hour", "in a minute". Past ones still read "3 days ago". Single units use the same article throughout ("a second", "a month", "a year") instead of mixing "one second" with "a minute". "now", "tomorrow" and "yesterday" are unchanged. --- .../RelativeTimeFormatterTests.cs | 60 +++++++++---------- .../TimeStringFormatterTests.cs | 4 +- .../Utilities/RelativeTimeFormatter.cs | 12 ++-- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/DesktopClock.Tests/RelativeTimeFormatterTests.cs b/DesktopClock.Tests/RelativeTimeFormatterTests.cs index ab57580..20ed00b 100644 --- a/DesktopClock.Tests/RelativeTimeFormatterTests.cs +++ b/DesktopClock.Tests/RelativeTimeFormatterTests.cs @@ -9,30 +9,30 @@ public class RelativeTimeFormatterTests [Theory] [InlineData("00:00:00.499", "now")] [InlineData("00:00:00.500", "now")] - [InlineData("00:00:01", "one second from now")] - [InlineData("00:00:59.999", "59 seconds from now")] - [InlineData("00:01:00", "a minute from now")] - [InlineData("00:01:59", "a minute from now")] - [InlineData("00:02:00", "2 minutes from now")] - [InlineData("00:59:59", "59 minutes from now")] - [InlineData("01:00:00", "an hour from now")] - [InlineData("01:29:59", "an hour from now")] - [InlineData("01:30:00", "an hour from now")] - [InlineData("02:00:00", "2 hours from now")] - [InlineData("23:59:59", "23 hours from 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", "2 days from now")] - [InlineData("2.00:00:00", "2 days from now")] - [InlineData("27.23:59:59", "27 days from now")] - [InlineData("28.00:00:00", "28 days from now")] - [InlineData("30.00:00:00", "one month from now")] - [InlineData("58.23:59:59", "one month from now")] - [InlineData("59.00:00:00", "2 months from now")] - [InlineData("344.23:59:59", "11 months from now")] - [InlineData("345.00:00:00", "one year from now")] - [InlineData("729.23:59:59", "one year from now")] - [InlineData("730.00:00:00", "2 years from now")] + [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); @@ -41,7 +41,7 @@ public void Format_PicksLargestUnitForFutureTarget(string offset, string expecte } [Theory] - [InlineData("00:00:01", "one second ago")] + [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")] @@ -49,9 +49,9 @@ public void Format_PicksLargestUnitForFutureTarget(string offset, string expecte [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", "one month ago")] + [InlineData("30.00:00:00", "a month ago")] [InlineData("90.00:00:00", "3 months ago")] - [InlineData("365.00:00:00", "one year 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) { @@ -65,12 +65,12 @@ 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("one month from now", RelativeTimeFormatter.Format(febNow.AddDays(28), febNow)); + 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("28 days from now", RelativeTimeFormatter.Format(marNow.AddDays(28), marNow)); - Assert.Equal("one month ago", RelativeTimeFormatter.Format(marNow.AddDays(-28), marNow)); + Assert.Equal("in 28 days", RelativeTimeFormatter.Format(marNow.AddDays(28), marNow)); + Assert.Equal("a month ago", RelativeTimeFormatter.Format(marNow.AddDays(-28), marNow)); } [Fact] @@ -78,7 +78,7 @@ 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("2 days from now", RelativeTimeFormatter.Format(lateNow.AddHours(25), lateNow)); + 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)); @@ -90,6 +90,6 @@ public void Format_IgnoresDateTimeKind() var target = DateTime.SpecifyKind(Now.AddHours(3), DateTimeKind.Utc); var now = DateTime.SpecifyKind(Now, DateTimeKind.Unspecified); - Assert.Equal("3 hours from now", RelativeTimeFormatter.Format(target, now)); + Assert.Equal("in 3 hours", RelativeTimeFormatter.Format(target, now)); } } diff --git a/DesktopClock.Tests/TimeStringFormatterTests.cs b/DesktopClock.Tests/TimeStringFormatterTests.cs index 8315c35..c08fcdf 100644 --- a/DesktopClock.Tests/TimeStringFormatterTests.cs +++ b/DesktopClock.Tests/TimeStringFormatterTests.cs @@ -116,9 +116,9 @@ public void Format_UsesRelativePhraseWhenCountdownFormatMissing() [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) { diff --git a/DesktopClock/Utilities/RelativeTimeFormatter.cs b/DesktopClock/Utilities/RelativeTimeFormatter.cs index fe010e8..4c52288 100644 --- a/DesktopClock/Utilities/RelativeTimeFormatter.cs +++ b/DesktopClock/Utilities/RelativeTimeFormatter.cs @@ -4,7 +4,7 @@ namespace DesktopClock; /// -/// Describes how far a moment is from now in plain English, such as "3 hours from now" or "yesterday". +/// Describes how far a moment is from now in plain English, such as "in 3 hours" or "yesterday". /// public static class RelativeTimeFormatter { @@ -21,7 +21,7 @@ public static string Format(DateTime target, DateTime now) return "now"; if (span.TotalSeconds < 60) - return Phrase(span.Seconds, "one second", "{0} seconds", isFuture); + return Phrase(span.Seconds, "a second", "{0} seconds", isFuture); if (span.TotalSeconds < 120) return Phrase(1, "a minute", "{0} minutes", isFuture); @@ -46,14 +46,14 @@ public static string Format(DateTime target, DateTime now) if (span.TotalDays < 30) { var sameDateNextMonth = now.Date.AddMonths(isFuture ? 1 : -1) == target.Date; - return sameDateNextMonth ? Phrase(1, "one month", "{0} months", isFuture) : Days(span.Days, isFuture); + 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), "one month", "{0} months", isFuture); + 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, "one year", "{0} years", isFuture); + return Phrase(years, "a year", "{0} years", isFuture); } private static string Days(int days, bool isFuture) => days switch @@ -69,6 +69,6 @@ private static string Phrase(int count, string single, string plural, bool isFut return "now"; var amount = count == 1 ? single : string.Format(CultureInfo.InvariantCulture, plural, count); - return isFuture ? amount + " from now" : amount + " ago"; + return isFuture ? "in " + amount : amount + " ago"; } } From 0c6592627be96a8e2506eaba53ad813c19db608a Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 21 Sep 2026 14:37:48 -0500 Subject: [PATCH 3/3] Update RelativeTimeFormatter.cs Signed-off-by: Daniel Chalmers --- DesktopClock/Utilities/RelativeTimeFormatter.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/DesktopClock/Utilities/RelativeTimeFormatter.cs b/DesktopClock/Utilities/RelativeTimeFormatter.cs index 4c52288..9f26fcb 100644 --- a/DesktopClock/Utilities/RelativeTimeFormatter.cs +++ b/DesktopClock/Utilities/RelativeTimeFormatter.cs @@ -9,8 +9,7 @@ namespace DesktopClock; public static class RelativeTimeFormatter { /// - /// Returns a phrase for the distance between and , - /// using the single largest unit that fits. + /// Returns a phrase for the distance between and , using the single largest unit that fits. /// public static string Format(DateTime target, DateTime now) {