@@ 0,0 1,207 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// The freshness line: handleView's Head on the envelope, the shared beadsHead
+// partial, and the ago func the two of them render. The line qualifies every
+// number on the page, so the tests that matter most here are the ones where it
+// is absent: a store with no history, and a log that cannot be read, must still
+// serve the board.
+
+// testNow is the instant the clock is pinned to in these tests. Nothing about
+// it is special beyond being fixed.
+var testNow = time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
+
+// pinClock replaces the package clock ago reads for the duration of a test, so
+// a relative time is a function of the fixture rather than of when the suite
+// ran.
+func pinClock(t *testing.T, at time.Time) {
+ t.Helper()
+ prev := timeNow
+ timeNow = func() time.Time { return at }
+ t.Cleanup(func() { timeNow = prev })
+}
+
+// headHash is a dolt-shaped object id; its first 8 characters are what
+// shortsha renders.
+const headHash = "qk9j2n4bh0ktbfjrn3f7f8m3s9d1p5v2"
+
+// withHead gives a fixture session a head commit four minutes old.
+func withHead(s *fakeSession) *fakeSession {
+ s.commits = []browse.CommitInfo{{
+ Hash: headHash,
+ Author: "Eugene Blikh",
+ Date: testNow.Add(-4 * time.Minute),
+ Message: "bd: create sr-ht-dolt-44n.2 (auto-commit)",
+ }}
+ return s
+}
+
+func TestAgoGranularity(t *testing.T) {
+ pinClock(t, testNow)
+
+ cases := []struct {
+ name string
+ d time.Duration // how long before the pinned now the instant is
+ want string
+ }{
+ {"the instant itself", 0, "just now"},
+ {"seconds", 45 * time.Second, "just now"},
+ {"one second short of a minute", time.Minute - time.Second, "just now"},
+ {"exactly a minute", time.Minute, "1 minute ago"},
+ {"minutes", 4 * time.Minute, "4 minutes ago"},
+ {"one second short of an hour", time.Hour - time.Second, "59 minutes ago"},
+ {"exactly an hour", time.Hour, "1 hour ago"},
+ {"hours", 3 * time.Hour, "3 hours ago"},
+ {"one second short of a day", 24*time.Hour - time.Second, "23 hours ago"},
+ {"exactly a day", 24 * time.Hour, "1 day ago"},
+ {"days", 2 * 24 * time.Hour, "2 days ago"},
+ {"one second short of a month", 30*24*time.Hour - time.Second, "29 days ago"},
+ {"exactly a month", 30 * 24 * time.Hour, "1 month ago"},
+ {"months", 71 * 24 * time.Hour, "2 months ago"},
+ {"one second short of a year", 365*24*time.Hour - time.Second, "12 months ago"},
+ {"exactly a year", 365 * 24 * time.Hour, "1 year ago"},
+ {"years", 3 * 365 * 24 * time.Hour, "3 years ago"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ assert.Equal(t, c.want, ago(testNow.Add(-c.d)))
+ })
+ }
+}
+
+// A commit stamped in the future is clock skew between whoever wrote it and
+// this host, not a scheduled event: it reads as "just now", never as a
+// forward-facing phrase and never as a negated count.
+func TestAgoOnAFutureInstant(t *testing.T) {
+ pinClock(t, testNow)
+
+ for _, ahead := range []time.Duration{time.Second, 3 * time.Minute, 48 * time.Hour} {
+ got := ago(testNow.Add(ahead))
+ assert.Equal(t, "just now", got, "an instant %s ahead of the clock", ahead)
+ assert.NotContains(t, got, "-", "a future instant must not render a negative count")
+ assert.NotContains(t, got, "in ", "the freshness line is past-facing")
+ }
+}
+
+func TestBeadsHeaderShowsFreshness(t *testing.T) {
+ pinClock(t, testNow)
+
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = withHead(beadsFixture())
+ setViews(t, h, &beadsView{})
+
+ rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code, "board: %s", rec.Body.String())
+ body := rec.Body.String()
+
+ assert.Contains(t, body, `class="beads-freshness"`)
+ // The branch being rendered, the relative age, and the short hash linking to
+ // the commit page — the three things the line is for.
+ assert.Contains(t, body, "main · last commit")
+ assert.Contains(t, body, "4 minutes ago")
+ assert.Contains(t, body, `href="/~alice/db/commit/`+headHash+`"`)
+ assert.Contains(t, body, "<code>qk9j2n4b</code>")
+ // The exact stamp stays one hover away.
+ assert.Contains(t, body, `title="2026-08-12 11:56:00 UTC"`)
+ // The board itself is unchanged around it.
+ assert.Contains(t, body, "Rolling")
+}
+
+func TestMilestonesHeaderShowsFreshness(t *testing.T) {
+ pinClock(t, testNow)
+
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = withHead(milestoneFixture())
+ setViews(t, h, &beadsView{}, &milestonesView{})
+
+ rec := h.do("GET", "/~alice/db/view/milestones", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code, "milestones: %s", rec.Body.String())
+ body := rec.Body.String()
+
+ assert.Contains(t, body, `class="beads-freshness"`)
+ assert.Contains(t, body, "main · last commit")
+ assert.Contains(t, body, "4 minutes ago")
+ assert.Contains(t, body, `href="/~alice/db/commit/`+headHash+`"`)
+ assert.Contains(t, body, "<code>qk9j2n4b</code>")
+ assert.Contains(t, body, "ms-title\">m1<")
+}
+
+// A database with no commits renders the board without the line. This is the
+// one that protects the rule: the freshness line is decoration on top of an
+// answer and may never be the reason a reader gets a 500 instead of a board.
+func TestViewHeaderOmittedWithoutHistory(t *testing.T) {
+ pinClock(t, testNow)
+
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ sess := beadsFixture()
+ sess.commits = nil // an initialised store nobody has committed to
+ h.browse.sess = sess
+ setViews(t, h, &beadsView{})
+
+ rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code, "board: %s", rec.Body.String())
+ body := rec.Body.String()
+
+ // The class name still appears in the page's scoped stylesheet, which is
+ // static; what must be absent is an element carrying it.
+ assert.NotContains(t, body, `class="beads-freshness"`)
+ assert.NotContains(t, body, "last commit")
+ // The page is otherwise the page.
+ assert.Contains(t, body, "Rolling")
+ assert.Contains(t, body, "Ready to roll")
+}
+
+// A log that cannot be read degrades the same way, rather than failing the
+// request.
+func TestViewHeaderOmittedWhenLogFails(t *testing.T) {
+ pinClock(t, testNow)
+
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ sess := withHead(beadsFixture())
+ sess.logErr = errors.New("browse: walk commits: corrupt chunk")
+ h.browse.sess = sess
+ setViews(t, h, &beadsView{})
+
+ rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code, "board: %s", rec.Body.String())
+ body := rec.Body.String()
+
+ // The class name still appears in the page's scoped stylesheet, which is
+ // static; what must be absent is an element carrying it.
+ assert.NotContains(t, body, `class="beads-freshness"`)
+ assert.NotContains(t, body, "last commit")
+ assert.Contains(t, body, "Rolling")
+}
+
+// headCommit is the envelope's one read, and returns nil on both failure arms
+// so the template has nothing to render.
+func TestHeadCommitDegradesToNil(t *testing.T) {
+ ctx := t.Context()
+
+ empty := &fakeSession{branches: []browse.Branch{{Name: "main"}}}
+ assert.Nil(t, headCommit(ctx, empty, "main"), "no commits")
+
+ failing := &fakeSession{logErr: errors.New("boom")}
+ assert.Nil(t, headCommit(ctx, failing, "main"), "log error")
+
+ ok := withHead(&fakeSession{})
+ got := headCommit(ctx, ok, "main")
+ require.NotNil(t, got)
+ assert.Equal(t, headHash, got.Hash)
+}
@@ 9,6 9,7 @@ import (
"net/http"
"net/url"
"strings"
+ "time"
"go.bigb.es/auxilia/scribe"
@@ 75,6 76,11 @@ func loadIcons() (map[string]template.HTML, error) {
// helpers nobody else has are listed here. The local copies of the relative and
// absolute time formatters are gone with the rest; chrome's reltime also faces
// forward ("in 3 weeks"), where ours called every future instant "just now".
+//
+// "ago" is the one time helper that came back, and deliberately under its own
+// name rather than as a shadow of reltime: the freshness line needs a
+// past-facing phrase and a clock it can be tested against, and the listings
+// that want chrome's forward-facing reltime keep it unchanged.
func templateFuncs(icons map[string]template.HTML) template.FuncMap {
m := template.FuncMap{}
@@ 99,10 105,60 @@ func templateFuncs(icons map[string]template.HTML) template.FuncMap {
// that switches one dimension of a page — the beads board/stream toggle —
// without re-listing the filters that are already set.
m["withQuery"] = withQuery
+ // ago is the freshness line's relative time: past-facing, coarse, and never
+ // negative. See the func for why it is not chrome's reltime.
+ m["ago"] = ago
return m
}
+// timeNow is the clock ago reads. It is a package variable so a test can pin it;
+// production never assigns it. A relative time built on a hidden time.Now is
+// untestable by construction, which is how a formatter's boundaries end up
+// asserted only by eye.
+var timeNow = time.Now
+
+// ago renders how long ago t was, coarsely: "just now", "4 minutes ago",
+// "3 hours ago", "2 days ago", "2 months ago", "1 year ago". The question it
+// answers is "is this page stale", not "how long exactly" — the exact stamp
+// belongs in the title attribute beside it (abstime).
+//
+// A future t — clock skew between whoever committed and this host — is "just
+// now" rather than "in 3 minutes" or, worse, a negated count. The freshness
+// line says how old the data is, and data cannot be younger than now; a
+// forward-facing phrase there would read as a claim about a scheduled event.
+// That is also why this is not chrome's reltime, which deliberately faces
+// forward for the deadlines other services render.
+//
+// Units follow chrome's ladder (minute → hour → day → month → year, months of
+// 30 days and years of 365), so the two spellings on one page cannot disagree
+// about which unit a duration falls into.
+func ago(t time.Time) string {
+ d := timeNow().Sub(t)
+ switch {
+ case d < time.Minute:
+ return "just now"
+ case d < time.Hour:
+ return plural(int(d/time.Minute), "minute") + " ago"
+ case d < 24*time.Hour:
+ return plural(int(d/time.Hour), "hour") + " ago"
+ case d < 30*24*time.Hour:
+ return plural(int(d/(24*time.Hour)), "day") + " ago"
+ case d < 365*24*time.Hour:
+ return plural(int(d/(30*24*time.Hour)), "month") + " ago"
+ default:
+ return plural(int(d/(365*24*time.Hour)), "year") + " ago"
+ }
+}
+
+// plural names a count in a unit, singular at one.
+func plural(n int, unit string) string {
+ if n == 1 {
+ return "1 " + unit
+ }
+ return fmt.Sprintf("%d %ss", n, unit)
+}
+
// doltHost renders the host:port for `dolt login --auth-endpoint` from an origin
// URL. It appends the default TLS/plain port when the origin omits one.
func doltHost(origin string) string {