From 89b6d3d68976173a268615d57ee4006349ee3bfc Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Tue, 11 Mar 2025 16:47:14 -0500 Subject: [PATCH] Humanize Time --- humanize/time.go | 121 ++++++++++++++++++++++++++++++++++++++++++ humanize/time_test.go | 72 +++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 humanize/time.go create mode 100644 humanize/time_test.go diff --git a/humanize/time.go b/humanize/time.go new file mode 100644 index 0000000..406c3ad --- /dev/null +++ b/humanize/time.go @@ -0,0 +1,121 @@ +package humanize + +import ( + "fmt" + "math" + "sort" + "time" +) + +const ( + Day = 24 * time.Hour + Week = 7 * Day + Month = 30 * Day + Year = 12 * Month + LongTime = 37 * Year +) + +// Format the time into a relative string from now. E.g. "5 minutes ago" or "5 minutes from now". +// Inspiration from: https://github.com/dustin/go-humanize/blob/master/times.go +func Time(ts time.Time) string { + return RelativeTime(ts, time.Now(), "ago", "from now", defaultMagnitudes) +} + +// A TimePeriod struct contains a relative time point at which the relative duration +// format will switch to a new format string. E.g. if the duration (D) is 1 minute, +// then we would express a relative duration as "x seconds ago/from now" for any +// duration within the time period. +// +// The format string must contain a single %s verb, which will be replaced with the +// appropriate time direction (e.g. "ago" or "from now") and a %d which will be replaced +// with the quantity of time periods. +type TimePeriod struct { + D time.Duration // The length of the time period + Format string // The format string to use for the time period + Den time.Duration // The denominator to use to determine the number of periods +} + +var defaultMagnitudes = []TimePeriod{ + {time.Second, "now", time.Second}, + {2 * time.Second, "1 second %s", 1}, + {time.Minute, "%d seconds %s", time.Second}, + {2 * time.Minute, "1 minute %s", 1}, + {time.Hour, "%d minutes %s", time.Minute}, + {2 * time.Hour, "1 hour %s", 1}, + {Day, "%d hours %s", time.Hour}, + {2 * Day, "1 day %s", 1}, + {Week, "%d days %s", Day}, + {2 * Week, "1 week %s", 1}, + {Month, "%d weeks %s", Week}, + {2 * Month, "1 month %s", 1}, + {Year, "%d months %s", Month}, + {18 * Month, "1 year %s", 1}, + {2 * Year, "2 years %s", 1}, + {LongTime, "%d years %s", Year}, + {math.MaxInt64, "a long while %s", 1}, +} + +// Since returns a string representing the time elapsed between t0 and t1. You must +// also pass in a label for the time period, e.g. "ago" or "earlier" to describe the +// amount of time that has passed. If t1 is before t0, you should use Until. +func Since(t0, t1 time.Time, label string) string { + return RelativeTime(t0, t1, label, "", defaultMagnitudes) +} + +// Until returns a string representing the time that will elapse between t0 and t1. You +// must also pass in a label for the time period, e.g. "from now" or "later" to describe +// the amount of time that will pass. If t1 is before t0, you should use Since. +func Until(t0, t1 time.Time, label string) string { + return RelativeTime(t0, t1, "", label, defaultMagnitudes) +} + +func RelativeTime(t0, t1 time.Time, earlier, later string, periods []TimePeriod) string { + // Determine which the direction of time based on the order of t0 and t1 + var ( + diff time.Duration + label string + ) + + if t1.After(t0) { + diff = t1.Sub(t0) + label = earlier + } else { + diff = t0.Sub(t1) + label = later + } + + // Find the period that best describes the time difference + n := sort.Search(len(periods), func(i int) bool { + return periods[i].D > diff + }) + + // If the diff is greater than any of the periods, use the last period + if n >= len(periods) { + n = len(periods) - 1 + } + + // Prepare the format string + mag := periods[n] + args := []interface{}{} + + // Check the characters for each format string to order the arguments correctly + escaped := false + for _, char := range mag.Format { + if char == '%' { + escaped = true + continue + } + + if escaped { + switch char { + case 's': + args = append(args, label) + case 'd': + args = append(args, diff/mag.Den) + } + escaped = false + } + } + + return fmt.Sprintf(mag.Format, args...) +} diff --git a/humanize/time_test.go b/humanize/time_test.go new file mode 100644 index 0000000..3c16690 --- /dev/null +++ b/humanize/time_test.go @@ -0,0 +1,72 @@ +package humanize_test + +import ( + "fmt" + "math" + "strings" + "testing" + "time" + + "go.rtnl.ai/x/assert" + "go.rtnl.ai/x/humanize" +) + +func TestTime(t *testing.T) { + tests := []struct { + time time.Time + suffix string + }{ + {time.Now().Add(-265 * time.Second), "ago"}, + {time.Now().Add(265 * time.Second), "from now"}, + } + + for i, tc := range tests { + dur := humanize.Time(tc.time) + assert.True(t, strings.HasSuffix(dur, tc.suffix), "test case %d failed", i) + } +} + +func TestRelativeTime(t *testing.T) { + t1 := time.Date(2021, 1, 21, 21, 21, 21, 0, time.UTC) + durations := []struct { + dur time.Duration + fmt string + }{ + {1500 * time.Millisecond, "1 second %s"}, + {31527 * time.Millisecond, "31 seconds %s"}, + {70 * time.Second, "1 minute %s"}, + {831 * time.Second, "13 minutes %s"}, + {98 * time.Minute, "1 hour %s"}, + {192 * time.Minute, "3 hours %s"}, + {32 * time.Hour, "1 day %s"}, + {98 * time.Hour, "4 days %s"}, + {9 * humanize.Day, "1 week %s"}, + {18 * humanize.Day, "2 weeks %s"}, + {6 * humanize.Week, "1 month %s"}, + {36 * humanize.Week, "8 months %s"}, + {14 * humanize.Month, "1 year %s"}, + {69 * humanize.Month, "5 years %s"}, + {180 * humanize.Year, "a long while %s"}, + {time.Duration(math.MaxInt64), "a long while %s"}, + } + + t.Run("Since", func(t *testing.T) { + assert.Equal(t, "now", humanize.Since(t1, t1, "ago"), "now test 1 failed") + assert.Equal(t, "now", humanize.Since(t1.Add(-500*time.Millisecond), t1, "ago"), "now test 2 failed") + + for i, tc := range durations { + t0 := t1.Add(-1 * tc.dur) + assert.Equal(t, fmt.Sprintf(tc.fmt, "ago"), humanize.Since(t0, t1, "ago"), "test case %d failed", i) + } + }) + + t.Run("Until", func(t *testing.T) { + assert.Equal(t, "now", humanize.Since(t1, t1, "from now"), "now test 1 failed") + assert.Equal(t, "now", humanize.Since(t1.Add(500*time.Millisecond), t1, "from now"), "now test 2 failed") + + for i, tc := range durations { + t0 := t1.Add(tc.dur) + assert.Equal(t, fmt.Sprintf(tc.fmt, "from now"), humanize.Until(t0, t1, "from now"), "test case %d failed", i) + } + }) +}