~bigbes/sr-ht-dolt

64592988d4a8f20befc11c22ce871a5eaacbf440 — Eugene Blikh 5 days ago dcdc979
web: show how fresh a beads view is

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: <branch> · last commit <relative> · <short hash>,
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.
A web/freshness_test.go => web/freshness_test.go +207 -0
@@ 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 &middot; 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 &middot; 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)
}

M web/handlers_view.go => web/handlers_view.go +19 -0
@@ 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]
}

M web/templates.go => web/templates.go +56 -0
@@ 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 {

M web/templates/_partials.html => web/templates/_partials.html +20 -0
@@ 37,6 37,26 @@
{{- end}}

{{/*
  beadsHead renders the freshness line the beads-family views carry under their
  title: "<branch> · last commit <relative> · <short hash>", 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}}
<p class="beads-freshness">{{$.Ref}} &middot; last commit
  <span title="{{.Date | abstime}}">{{.Date | ago}}</span> &middot;
  <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.Hash}}"><code>{{.Hash | shortsha}}</code></a></p>
{{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;
  handlers build its chrome.RepoList dot (repoList in handlers_repo.go).

M web/templates/beads.html => web/templates/beads.html +8 -0
@@ 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 {

<div class="beads">
<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> &middot; parade</h2>
{{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")}}

M web/templates/milestones.html => web/templates/milestones.html +7 -0
@@ 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; }
</style>

<div class="milestones">
<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> &middot; milestones</h2>
{{template "beadsHead" (dict "Repo" .Repo "Ref" .Ref "Head" .Head)}}
{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "milestones" "Ref" .Ref)}}

{{if .Data.Milestones}}

M web/web_test.go => web/web_test.go +7 -1
@@ 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) {