Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 51 additions & 28 deletions internal/display/display.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
33 changes: 22 additions & 11 deletions internal/display/display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Expand All @@ -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"},
}
Expand Down