From 64592988d4a8f20befc11c22ce871a5eaacbf440 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 13 Aug 2026 08:26:47 +0300 Subject: [PATCH] web: show how fresh a beads view is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleView now reads the head commit of the rendered ref onto the envelope (Head *browse.CommitInfo), and the beads and milestones headers carry a shared beadsHead partial: · last commit · , the hash linking to the commit page and the exact stamp in the title. The read is decoration on top of an answer: a database with no commits, or a Log that fails, renders the page without the line rather than 500ing. The relative time is a new ago func rather than chrome's reltime — it is past-facing (clock skew reads as "just now", never "in 3 minutes") and reads a package clock a test can pin. --- web/freshness_test.go | 207 ++++++++++++++++++++++++++++++++++ web/handlers_view.go | 19 ++++ web/templates.go | 56 +++++++++ web/templates/_partials.html | 20 ++++ web/templates/beads.html | 8 ++ web/templates/milestones.html | 7 ++ web/web_test.go | 8 +- 7 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 web/freshness_test.go diff --git a/web/freshness_test.go b/web/freshness_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d828cab9a50d395f26f641bf731dba10e38898c5 --- /dev/null +++ b/web/freshness_test.go @@ -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, "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 @@ func (a *app) handleView(w http.ResponseWriter, r *http.Request) { 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 @@ func (a *app) handleView(w http.ResponseWriter, r *http.Request) { 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 @@ 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 { 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 @@ -36,6 +36,26 @@ {{- end}} +{{/* + beadsHead renders the freshness line the beads-family views carry under their + title: " · last commit · ", the hash linking to + the commit page and the exact stamp one hover away in the title attribute. + Invoke it with a dict context, as viewtabs above: + {{template "beadsHead" (dict "Repo" .Repo "Ref" .Ref "Head" .Head)}} + .Head is the envelope's *browse.CommitInfo (handleView fills it for every + view). It is nil for a database with no commits, or when reading the log + failed, and then this renders nothing at all: the line says how fresh the page + is, and a line that cannot answer that must not draw a shape that looks like + an answer. The class is styled by each page's own scoped stylesheet. +*/}} +{{define "beadsHead" -}} +{{with .Head}} +

{{$.Ref}} · last commit + {{.Date | ago}} · + {{.Hash | shortsha}}

+{{end}} +{{- end}} + {{/* The database listing used to live here as "repoList". It is now sr-ht-ecore's "srht-repo-list", whose markup this copy already matched line for line; diff --git a/web/templates/beads.html b/web/templates/beads.html index ae62eae66ff756f2aee9b56edec9799f08294ce0..0c57da5ea640811005b7f0704566c8c7d226b44c 100644 --- a/web/templates/beads.html +++ b/web/templates/beads.html @@ -29,6 +29,13 @@ .beads .past-stand { --lane: var(--lane-past); } .beads .total { --lane: var(--bd-muted); } +/* freshness line (the shared "beadsHead" partial): one quiet row under the + title, muted and small — it qualifies the page, it does not announce + anything. */ +.beads .beads-freshness { font-size: .78rem; color: var(--bd-muted); margin: -.35rem 0 .7rem; } +.beads .beads-freshness a { color: var(--bd-muted); } +.beads .beads-freshness code { font-size: .72rem; color: inherit; } + /* a small square colour chip standing in for the lane's Mardi Gras hue */ .beads .swatch { display: inline-block; width: .7rem; height: .7rem; flex: 0 0 auto; @@ -188,6 +195,7 @@ pre.field-body {

~{{.Repo.OwnerName}}/{{.Repo.Name}} · parade

+{{template "beadsHead" (dict "Repo" .Repo "Ref" .Ref "Head" .Head)}} {{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "beads" "Ref" .Ref)}} {{if or (eq .Data.Mode "detail") (eq .Data.Mode "epic")}} diff --git a/web/templates/milestones.html b/web/templates/milestones.html index 96d977bb5f4c62537a7c33dd8df9d051e809fe5f..bc04b3e137ea3b7703ffa529cce1d8522c182948 100644 --- a/web/templates/milestones.html +++ b/web/templates/milestones.html @@ -43,12 +43,19 @@ .milestones .blabel.muted { color: var(--bd-muted); } .milestones .blabel.assignee { border-color: var(--accent); } +/* freshness line (the shared "beadsHead" partial), in the same muted small type + the beads view gives it */ +.milestones .beads-freshness { font-size: .78rem; color: var(--bd-muted); margin: -.35rem 0 .7rem; } +.milestones .beads-freshness a { color: var(--bd-muted); } +.milestones .beads-freshness code { font-size: .72rem; color: inherit; } + .milestones .ms-empty { color: var(--bd-muted); font-style: italic; } .milestones .ms-foot { color: var(--bd-muted); font-size: .82rem; margin-top: .5rem; }

~{{.Repo.OwnerName}}/{{.Repo.Name}} · milestones

+{{template "beadsHead" (dict "Repo" .Repo "Ref" .Ref "Head" .Head)}} {{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "milestones" "Ref" .Ref)}} {{if .Data.Milestones}} diff --git a/web/web_test.go b/web/web_test.go index 18846754cb1be6b583cddd244c49d28bae3da92d..9a1ff5f466edbd475c38f67b06916f53f3296b35 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -242,11 +242,17 @@ type fakeSession struct { // from a non-nil map is reported as ErrTableNotFound, mirroring the store. rowsByTable map[string]*browse.RowPage summary *browse.CommitDiff - closed bool + // logErr, when set, makes Log fail — a store whose history cannot be read, + // which every page reading the log for decoration has to survive. + logErr error + closed bool } func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil } func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) { + if s.logErr != nil { + return nil, "", s.logErr + } return s.commits, "", nil } func (s *fakeSession) Tables(_ context.Context, _ string) ([]browse.TableInfo, error) {