From 84732b4a0b6e6699185ae00bdbb9641c8ec885e5 Mon Sep 17 00:00:00 2001 From: Ilya Eryomenko Date: Thu, 11 Jun 2026 23:48:41 +0500 Subject: [PATCH 1/2] Fix relative time text --- internal/display/display.go | 48 +++++++++++++++++--------------- internal/display/display_test.go | 10 ++++--- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/internal/display/display.go b/internal/display/display.go index 445b297..89dddb8 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -297,8 +297,20 @@ func relativeTime(t time.Time) string { now := time.Now() diff := now.Sub(t) - if diff < 0 { + future := diff < 0 + if future { diff = -diff + } + if diff < 24*time.Hour { + return "today" + } + if diff < 48*time.Hour { + if future { + return "tomorrow" + } + return "yesterday" + } + if future { return formatDuration(diff) + " from now" } return formatDuration(diff) + " ago" @@ -307,32 +319,22 @@ func relativeTime(t time.Time) string { func formatDuration(d time.Duration) string { days := int(d.Hours() / 24) - if days < 1 { - return "today" - } - if days == 1 { - return "1 day" - } if days < 30 { - return fmt.Sprintf("%d days", days) + return plural(days, "day") } if days < 365 { - months := days / 30 - if months == 1 { - return "1 month" - } - return fmt.Sprintf("%d months", months) + return plural(days/30, "month") } - years := days / 365 - remainingMonths := (days % 365) / 30 - if years == 1 && remainingMonths == 0 { - return "1 year" + s := plural(days/365, "year") + if months := (days % 365) / 30; months > 0 { + s += ", " + plural(months, "month") } - if remainingMonths == 0 { - return fmt.Sprintf("%d years", years) - } - if years == 1 { - return fmt.Sprintf("1 year, %d months", remainingMonths) + return s +} + +func plural(n int, unit string) string { + if n == 1 { + return "1 " + unit } - return fmt.Sprintf("%d years, %d months", years, remainingMonths) + return fmt.Sprintf("%d %ss", n, unit) } diff --git a/internal/display/display_test.go b/internal/display/display_test.go index aa8d5c8..ea3cbb7 100644 --- a/internal/display/display_test.go +++ b/internal/display/display_test.go @@ -13,8 +13,10 @@ func TestRelativeTime(t *testing.T) { t time.Time want string }{ - {"today", now, "today ago"}, - {"yesterday", now.Add(-24 * time.Hour), "1 day ago"}, + {"today", now, "today"}, + {"later today", now.Add(1 * time.Hour), "today"}, + {"yesterday", now.Add(-24 * time.Hour), "yesterday"}, + {"tomorrow", now.Add(36 * time.Hour), "tomorrow"}, {"5 days ago", now.Add(-5 * 24 * time.Hour), "5 days ago"}, {"2 months ago", now.Add(-60 * 24 * time.Hour), "2 months ago"}, {"1 year ago", now.Add(-365 * 24 * time.Hour), "1 year ago"}, @@ -36,13 +38,13 @@ func TestFormatDuration(t *testing.T) { days int want string }{ - {0, "today"}, {1, "1 day"}, + {2, "2 days"}, {15, "15 days"}, {30, "1 month"}, {90, "3 months"}, {365, "1 year"}, - {400, "1 year, 1 months"}, + {400, "1 year, 1 month"}, {730, "2 years"}, {800, "2 years, 2 months"}, } From 85f7ce33e4011857496f25a1bc9153e736117a8d Mon Sep 17 00:00:00 2001 From: Ben Word Date: Tue, 23 Jun 2026 16:29:09 -0500 Subject: [PATCH 2/2] Base today/yesterday/tomorrow on calendar dates The previous logic bucketed by elapsed hours (<24h/<48h), so a timestamp less than a day away but on a different calendar date could be mislabeled (e.g. a row reading "2026-06-24 today"). Since dateRow prints the civil date via t.Format("2006-01-02"), compare civil dates instead via a zone-independent day index so the label always matches the visible date, including timestamps with explicit offsets. Add a testable relativeTimeFrom seam so the labels can be asserted deterministically. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/display/display.go | 39 ++++++++++++++++++++++++-------- internal/display/display_test.go | 29 ++++++++++++++++-------- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/internal/display/display.go b/internal/display/display.go index 89dddb8..ddfa153 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -294,26 +294,47 @@ func renderContact(c model.Contact) string { } func relativeTime(t time.Time) string { - now := time.Now() - diff := now.Sub(t) + return relativeTimeFrom(time.Now(), t) +} - future := diff < 0 +// relativeTimeFrom labels t relative to now. Because dateRow shows only the +// calendar date (YYYY-MM-DD), the today/yesterday/tomorrow labels are based on +// the calendar-day difference rather than elapsed hours — otherwise a time less +// than 24h away but on a different date would be mislabeled (e.g. a row reading +// "2026-06-24 today" when it is still the 23rd). +func relativeTimeFrom(now, t time.Time) string { + days := calendarDaysBetween(t, now) // positive when t is in the past + future := days < 0 if future { - diff = -diff + days = -days } - if diff < 24*time.Hour { + switch days { + case 0: return "today" - } - if diff < 48*time.Hour { + case 1: if future { return "tomorrow" } return "yesterday" } + d := formatDuration(time.Duration(days) * 24 * time.Hour) if future { - return formatDuration(diff) + " from now" + return d + " from now" + } + return d + " ago" +} + +// calendarDaysBetween returns the difference in calendar days between the civil +// dates of t and now (positive when now is the later date). Each timestamp's +// date is taken in its own location — matching what dateRow prints via +// t.Format("2006-01-02") — then mapped to a UTC day index, so the count reflects +// the displayed dates exactly regardless of time zone or DST. +func calendarDaysBetween(t, now time.Time) int { + dayIndex := func(x time.Time) int { + y, m, d := x.Date() + return int(time.Date(y, m, d, 0, 0, 0, 0, time.UTC).Unix() / 86400) } - return formatDuration(diff) + " ago" + return dayIndex(now) - dayIndex(t) } func formatDuration(d time.Duration) string { diff --git a/internal/display/display_test.go b/internal/display/display_test.go index ea3cbb7..bb4e907 100644 --- a/internal/display/display_test.go +++ b/internal/display/display_test.go @@ -6,7 +6,9 @@ import ( ) func TestRelativeTime(t *testing.T) { - now := time.Now() + // Fixed reference instant so calendar-day labels are deterministic + // regardless of the time of day the test runs. + now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC) tests := []struct { name string @@ -14,20 +16,27 @@ func TestRelativeTime(t *testing.T) { want string }{ {"today", now, "today"}, - {"later today", now.Add(1 * time.Hour), "today"}, - {"yesterday", now.Add(-24 * time.Hour), "yesterday"}, - {"tomorrow", now.Add(36 * time.Hour), "tomorrow"}, - {"5 days ago", now.Add(-5 * 24 * time.Hour), "5 days ago"}, - {"2 months ago", now.Add(-60 * 24 * time.Hour), "2 months ago"}, - {"1 year ago", now.Add(-365 * 24 * time.Hour), "1 year ago"}, - {"future", now.Add(95 * 24 * time.Hour), "3 months from now"}, + {"earlier today", now.Add(-8 * time.Hour), "today"}, + {"later today", now.Add(8 * time.Hour), "today"}, + {"yesterday", now.AddDate(0, 0, -1), "yesterday"}, + {"tomorrow", now.AddDate(0, 0, 1), "tomorrow"}, + // <24h away but on an adjacent calendar date: labeled by date, not hours. + {"late yesterday within 24h", now.Add(-20 * time.Hour), "yesterday"}, + {"early tomorrow within 24h", now.Add(20 * time.Hour), "tomorrow"}, + // Different zone: civil date 06-24 (what dateRow prints) is the next day + // even though the instant is only ~3h after now. + {"next date in another zone", time.Date(2026, 6, 24, 0, 0, 0, 0, time.FixedZone("JST", 9*3600)), "tomorrow"}, + {"5 days ago", now.AddDate(0, 0, -5), "5 days ago"}, + {"2 months ago", now.AddDate(0, 0, -60), "2 months ago"}, + {"1 year ago", now.AddDate(0, 0, -365), "1 year ago"}, + {"future", now.AddDate(0, 0, 95), "3 months from now"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := relativeTime(tt.t) + got := relativeTimeFrom(now, tt.t) if got != tt.want { - t.Errorf("relativeTime() = %q, want %q", got, tt.want) + t.Errorf("relativeTimeFrom() = %q, want %q", got, tt.want) } }) }