1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
package main

// How long ago, in words.
//
// This began inside the iCalendar reader, where the question was "is this
// booking still ahead of me". It is the same question a contacts listing and a
// mirror footer ask in the past tense — "when did I last hear from this person",
// "when did I last hear from the server" — so the wording lives here rather than
// beside the parser that happened to need it first.
//
// One implementation matters more than the tidiness. A relative date is an
// assertion about *now*, and two of them that round differently are two
// statements that disagree while both claiming to be mailweb's own voice: a
// contact last seen at the same instant as an event would read "an hour ago" in
// one view and "60 minutes ago" in the other.

import (
	"fmt"
	"strings"
	"time"
)

// relativeTime renders the distance between two instants in words.
//
// now is a parameter rather than read from the clock so the wording can be
// tested against a fixed instant rather than against whenever the suite happens
// to run.
//
// Day distances are counted between calendar days rather than by dividing a
// duration: an event at 23:00 tonight and one at 01:00 tomorrow are three hours
// apart but are "today" and "tomorrow", which is what a reader means by those
// words. Dividing the duration would call both "in 0 days".
func relativeTime(t, now time.Time) string {
	// Compare calendar days in the subject's own location, since that is the
	// clock it is stated in.
	day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
	today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, t.Location())
	days := int(day.Sub(today).Hours() / 24)

	switch {
	case days == 0:
		// Within today, the hours are worth having: "in 2 hours" is a different
		// message from "9 hours ago", and both are "today".
		d := t.Sub(now)
		switch {
		case d >= time.Hour:
			return "in " + countOf(int(d.Hours()), "an hour", "%d hours")
		case d >= time.Minute:
			return "in " + countOf(int(d.Minutes()), "a minute", "%d minutes")
		case d > 0:
			return "in a moment"
		case d > -time.Minute:
			// The mirror footer lives here almost all the time, and this case
			// is why it is not simply "%d minutes ago": that spelled the first
			// minute after a successful sync — the healthiest state mailweb is
			// ever in — as "0 minutes ago", which reads like a broken counter
			// rather than like a fresh answer.
			return "a moment ago"
		case d > -time.Hour:
			return countOf(int(-d.Minutes()), "a minute", "%d minutes") + " ago"
		default:
			return countOf(int(-d.Hours()), "an hour", "%d hours") + " ago"
		}
	case days == 1:
		return "tomorrow"
	case days == -1:
		return "yesterday"
	case days > 0:
		return "in " + coarseDuration(days)
	default:
		return coarseDuration(-days) + " ago"
	}
}

// ============================================================================
// The age column of a text rendering
// ============================================================================

// ageColumn renders "(3 minutes ago)" for the rows of one listing, padded to
// the width of the widest of them.
//
// It is built per page rather than padded to a fixed width because the widest
// possible age — "11 years and 11 months ago", and wider still for the
// malformed dates a mail archive always contains — is more than twice the
// common one, and a text rendering is read by something that pays per token.
// A page of recent correspondents costs the width of "3 minutes ago"; a page of
// old ones costs what it needs. Fixed padding would spend the worst case on
// every row of every page.
//
// It also fixes the instant. Every row of one listing is measured against the
// same now, so a page cannot contain two rows whose ages disagree because the
// clock moved while it rendered.
type ageColumn struct {
	now   time.Time
	width int
}

// newAgeColumn measures the ages of every row that will be printed.
//
// The caller passes the times it is about to render, after paging: the width
// belongs to the page, and measuring the whole result set would pad page one to
// the width of the oldest contact on page forty.
func newAgeColumn(now time.Time, times []time.Time) ageColumn {
	c := ageColumn{now: now}
	for _, t := range times {
		if n := len([]rune(c.render(t))); n > c.width {
			c.width = n
		}
	}
	return c
}

// Of renders one row's age, padded to the column width.
//
// Padding only ever adds: a value wider than the column — which cannot happen
// for a time the column was measured over, and can for one it was not — comes
// out whole and merely pushes the rest of its line right, rather than being
// truncated into a different claim about when something happened.
func (c ageColumn) Of(t time.Time) string {
	s := c.render(t)
	if n := len([]rune(s)); n < c.width {
		return s + strings.Repeat(" ", c.width-n)
	}
	return s
}

func (c ageColumn) render(t time.Time) string {
	now := c.now
	if now.IsZero() {
		// A column nobody measured — a handler that filled in its view data
		// and forgot this field. Measuring against the zero time would print
		// "in 292 years and 5 months" beside every row: not a missing answer
		// but a confident wrong one, in mailweb's own voice, next to a date
		// that contradicts it. The clock costs a syscall and is right.
		now = time.Now()
	}
	return "(" + relativeTime(t, now) + ")"
}

// countOf spells a count, naming one as a word rather than as a digit.
//
// "an hour ago" against "1 hours ago": the plural is what a truncating division
// produces for everything between one and two of a unit, and it is the most
// common value of every one of these buckets.
func countOf(n int, one, many string) string {
	if n == 1 {
		return one
	}
	return fmt.Sprintf(many, n)
}

// coarseDuration renders a whole number of days in the largest unit that does
// not overstate the precision, carrying a second unit past a year.
//
// The second unit is there because a mail archive is mostly old: 83 of the 132
// calendar attachments here are more than a year back, and with years alone
// every one of them read "a year ago" — a single bucket holding most of the
// corpus, which is the least useful thing a relative date can say. "a year and
// 3 months ago" separates them at the resolution the reader actually wants.
//
// It stops at two units. Weeks are not added to months, and days not to weeks,
// because below a year the leading figure already carries the answer and a
// remainder would only add noise to a number that is approximate anyway — for a
// calendar event these are day counts across timezones that are deliberately
// never converted (see FormatWhen), so the last unit of precision was never
// real.
func coarseDuration(days int) string {
	switch {
	case days < 7:
		return fmt.Sprintf("%d days", days)
	case days < 31:
		return countOf(days/7, "a week", "%d weeks")
	case days < 365:
		return countOf(days/30, "a month", "%d months")
	default:
		years := days / 365
		months := (days % 365) / 30

		out := countOf(years, "a year", "%d years")
		switch {
		case months == 0:
			return out
		case months == 1:
			return out + " and a month"
		default:
			return fmt.Sprintf("%s and %d months", out, months)
		}
	}
}