qk9j2n4b")
+ // 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, "qk9j2n4b")
+ 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)
+}
diff --git a/web/handlers_view.go b/web/handlers_view.go
index cc033c74cb5e80961612d27afda7ce29c3bd07bd..400d18ccd8ebdfc274d816a35e3cef419a862290 100644
--- a/web/handlers_view.go
+++ b/web/handlers_view.go
@@ -1,6 +1,7 @@
package web
import (
+ "context"
"errors"
"net/http"
@@ -84,6 +85,7 @@ Repo *core.Repo
Ref string
Branches []browse.Branch
Views []View
+ Head *browse.CommitInfo
Data any
}{
Page: a.page(r, view.Label()+" — "+repo.OwnerName+"/"+repo.Name),
@@ -91,7 +93,24 @@ Repo: repo,
Ref: ref,
Branches: branches,
Views: applicableViews(a.views, tables),
+ Head: headCommit(r.Context(), sess, ref),
Data: data,
}
a.render(w, http.StatusOK, pageName(view.Template()), envelope)
}
+
+// headCommit reads the head commit of ref for the envelope's freshness line: a
+// board rendered from a store that stopped receiving pushes yesterday is
+// otherwise indistinguishable from a current one.
+//
+// It returns nil rather than an error, and that is the whole contract. A
+// database with no commits, or a Log that fails, must still render the view —
+// this is decoration on top of an answer, and it may never be the reason a
+// reader gets a 500 instead of a board. The partial renders nothing for nil.
+func headCommit(ctx context.Context, sess BrowseSession, ref string) *browse.CommitInfo {
+ commits, _, err := sess.Log(ctx, ref, "", 1)
+ if err != nil || len(commits) == 0 {
+ return nil
+ }
+ return &commits[0]
+}
diff --git a/web/templates.go b/web/templates.go
index d72428de5b095d844481f3486484d1f05e75f1a8..df4659fbffbfd359306f0ca4b0a867df9b30af09 100644
--- a/web/templates.go
+++ b/web/templates.go
@@ -9,6 +9,7 @@ "log/slog"
"net/http"
"net/url"
"strings"
+ "time"
"go.bigb.es/auxilia/scribe"
@@ -75,6 +76,11 @@ // partials and half this family's pages were written against — so only the
// 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,8 +105,58 @@ // withQuery rebuilds a request's query with one key replaced, for a link
// 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
diff --git a/web/templates/_partials.html b/web/templates/_partials.html
index 4cd9706b920b74ebe4aebce8dcb6020ba60c9159..7ae1fa11452521e6b85f31eda0a00cbe1de440cc 100644
--- a/web/templates/_partials.html
+++ b/web/templates/_partials.html
@@ -37,6 +37,26 @@
{{- end}}
{{/*
+ beadsHead renders the freshness line the beads-family views carry under their
+ title: "{{$.Ref}} · last commit
+ {{.Date | ago}} ·
+ {{.Hash | shortsha}}