Skip to content

Commit edbd6ea

Browse files
GLobyNewretlehsclaude
authored
Fix relative time text (e.g "today ago") (#53)
* Fix relative time text * 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) <noreply@anthropic.com> --------- Co-authored-by: Ben Word <ben@benword.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 34e5e73 commit edbd6ea

2 files changed

Lines changed: 73 additions & 39 deletions

File tree

internal/display/display.go

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -294,45 +294,68 @@ func renderContact(c model.Contact) string {
294294
}
295295

296296
func relativeTime(t time.Time) string {
297-
now := time.Now()
298-
diff := now.Sub(t)
297+
return relativeTimeFrom(time.Now(), t)
298+
}
299+
300+
// relativeTimeFrom labels t relative to now. Because dateRow shows only the
301+
// calendar date (YYYY-MM-DD), the today/yesterday/tomorrow labels are based on
302+
// the calendar-day difference rather than elapsed hours — otherwise a time less
303+
// than 24h away but on a different date would be mislabeled (e.g. a row reading
304+
// "2026-06-24 today" when it is still the 23rd).
305+
func relativeTimeFrom(now, t time.Time) string {
306+
days := calendarDaysBetween(t, now) // positive when t is in the past
307+
future := days < 0
308+
if future {
309+
days = -days
310+
}
311+
switch days {
312+
case 0:
313+
return "today"
314+
case 1:
315+
if future {
316+
return "tomorrow"
317+
}
318+
return "yesterday"
319+
}
320+
d := formatDuration(time.Duration(days) * 24 * time.Hour)
321+
if future {
322+
return d + " from now"
323+
}
324+
return d + " ago"
325+
}
299326

300-
if diff < 0 {
301-
diff = -diff
302-
return formatDuration(diff) + " from now"
327+
// calendarDaysBetween returns the difference in calendar days between the civil
328+
// dates of t and now (positive when now is the later date). Each timestamp's
329+
// date is taken in its own location — matching what dateRow prints via
330+
// t.Format("2006-01-02") — then mapped to a UTC day index, so the count reflects
331+
// the displayed dates exactly regardless of time zone or DST.
332+
func calendarDaysBetween(t, now time.Time) int {
333+
dayIndex := func(x time.Time) int {
334+
y, m, d := x.Date()
335+
return int(time.Date(y, m, d, 0, 0, 0, 0, time.UTC).Unix() / 86400)
303336
}
304-
return formatDuration(diff) + " ago"
337+
return dayIndex(now) - dayIndex(t)
305338
}
306339

307340
func formatDuration(d time.Duration) string {
308341
days := int(d.Hours() / 24)
309342

310-
if days < 1 {
311-
return "today"
312-
}
313-
if days == 1 {
314-
return "1 day"
315-
}
316343
if days < 30 {
317-
return fmt.Sprintf("%d days", days)
344+
return plural(days, "day")
318345
}
319346
if days < 365 {
320-
months := days / 30
321-
if months == 1 {
322-
return "1 month"
323-
}
324-
return fmt.Sprintf("%d months", months)
347+
return plural(days/30, "month")
325348
}
326-
years := days / 365
327-
remainingMonths := (days % 365) / 30
328-
if years == 1 && remainingMonths == 0 {
329-
return "1 year"
349+
s := plural(days/365, "year")
350+
if months := (days % 365) / 30; months > 0 {
351+
s += ", " + plural(months, "month")
330352
}
331-
if remainingMonths == 0 {
332-
return fmt.Sprintf("%d years", years)
333-
}
334-
if years == 1 {
335-
return fmt.Sprintf("1 year, %d months", remainingMonths)
353+
return s
354+
}
355+
356+
func plural(n int, unit string) string {
357+
if n == 1 {
358+
return "1 " + unit
336359
}
337-
return fmt.Sprintf("%d years, %d months", years, remainingMonths)
360+
return fmt.Sprintf("%d %ss", n, unit)
338361
}

internal/display/display_test.go

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,37 @@ import (
66
)
77

88
func TestRelativeTime(t *testing.T) {
9-
now := time.Now()
9+
// Fixed reference instant so calendar-day labels are deterministic
10+
// regardless of the time of day the test runs.
11+
now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC)
1012

1113
tests := []struct {
1214
name string
1315
t time.Time
1416
want string
1517
}{
16-
{"today", now, "today ago"},
17-
{"yesterday", now.Add(-24 * time.Hour), "1 day ago"},
18-
{"5 days ago", now.Add(-5 * 24 * time.Hour), "5 days ago"},
19-
{"2 months ago", now.Add(-60 * 24 * time.Hour), "2 months ago"},
20-
{"1 year ago", now.Add(-365 * 24 * time.Hour), "1 year ago"},
21-
{"future", now.Add(95 * 24 * time.Hour), "3 months from now"},
18+
{"today", now, "today"},
19+
{"earlier today", now.Add(-8 * time.Hour), "today"},
20+
{"later today", now.Add(8 * time.Hour), "today"},
21+
{"yesterday", now.AddDate(0, 0, -1), "yesterday"},
22+
{"tomorrow", now.AddDate(0, 0, 1), "tomorrow"},
23+
// <24h away but on an adjacent calendar date: labeled by date, not hours.
24+
{"late yesterday within 24h", now.Add(-20 * time.Hour), "yesterday"},
25+
{"early tomorrow within 24h", now.Add(20 * time.Hour), "tomorrow"},
26+
// Different zone: civil date 06-24 (what dateRow prints) is the next day
27+
// even though the instant is only ~3h after now.
28+
{"next date in another zone", time.Date(2026, 6, 24, 0, 0, 0, 0, time.FixedZone("JST", 9*3600)), "tomorrow"},
29+
{"5 days ago", now.AddDate(0, 0, -5), "5 days ago"},
30+
{"2 months ago", now.AddDate(0, 0, -60), "2 months ago"},
31+
{"1 year ago", now.AddDate(0, 0, -365), "1 year ago"},
32+
{"future", now.AddDate(0, 0, 95), "3 months from now"},
2233
}
2334

2435
for _, tt := range tests {
2536
t.Run(tt.name, func(t *testing.T) {
26-
got := relativeTime(tt.t)
37+
got := relativeTimeFrom(now, tt.t)
2738
if got != tt.want {
28-
t.Errorf("relativeTime() = %q, want %q", got, tt.want)
39+
t.Errorf("relativeTimeFrom() = %q, want %q", got, tt.want)
2940
}
3041
})
3142
}
@@ -36,13 +47,13 @@ func TestFormatDuration(t *testing.T) {
3647
days int
3748
want string
3849
}{
39-
{0, "today"},
4050
{1, "1 day"},
51+
{2, "2 days"},
4152
{15, "15 days"},
4253
{30, "1 month"},
4354
{90, "3 months"},
4455
{365, "1 year"},
45-
{400, "1 year, 1 months"},
56+
{400, "1 year, 1 month"},
4657
{730, "2 years"},
4758
{800, "2 years, 2 months"},
4859
}

0 commit comments

Comments
 (0)