diff --git a/internal/display/display.go b/internal/display/display.go index 445b297..ddfa153 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -294,45 +294,68 @@ func renderContact(c model.Contact) string { } func relativeTime(t time.Time) string { - now := time.Now() - diff := now.Sub(t) + return relativeTimeFrom(time.Now(), t) +} + +// 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 { + days = -days + } + switch days { + case 0: + return "today" + case 1: + if future { + return "tomorrow" + } + return "yesterday" + } + d := formatDuration(time.Duration(days) * 24 * time.Hour) + if future { + return d + " from now" + } + return d + " ago" +} - if diff < 0 { - diff = -diff - return formatDuration(diff) + " from now" +// 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 { 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..bb4e907 100644 --- a/internal/display/display_test.go +++ b/internal/display/display_test.go @@ -6,26 +6,37 @@ 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 t time.Time want string }{ - {"today", now, "today ago"}, - {"yesterday", now.Add(-24 * time.Hour), "1 day ago"}, - {"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"}, + {"today", now, "today"}, + {"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) } }) } @@ -36,13 +47,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"}, }