~bigbes/sr-ht-dolt

5c5479c4040cb4ba231cfe09ddae8634879ad5d9 — Eugene Blikh 29 days ago 88a1d37
feat(web/beads): ready markers, milestone rollup, transitive dependency tree

Three additions surfacing more of the bd data model:

- Ready: a ⚡ marker on actionable-now cards and a "Ready only" board filter.
  "Ready" mirrors bd's ready set exactly — open, unblocked, not template/
  ephemeral. As part of this, parent-child edges no longer count as blockers
  (a subtask is not blocked by its open epic), which was over-filling Stalled
  and under-counting ready; the board now matches bd's is_blocked/ready
  accounting (verified: 111 ready on tarantool-etcd, == ready_issues).

- Milestone rollup: a board panel with per-"milestone:<name>" label progress
  bars (done/total), each linking to that label's filtered board.

- Transitive dependency tree on the detail page: the full prerequisite chain
  ("everything this waits on") and the reverse ("everything this unblocks"),
  walked from the edge set with depth/'node caps and cycle guard, indented by
  depth. Shown only when it reaches past the direct edges, so it never just
  repeats the flat Depends-on / Depended-on-by lists.
4 files changed, 401 insertions(+), 13 deletions(-)

M web/beads.go
M web/beads_test.go
M web/realdata_test.go
M web/templates/beads.html
M web/beads.go => web/beads.go +187 -9
@@ 71,14 71,21 @@ type BeadsData struct {
	ShownOf    int  // when Truncated: the reported table total
	Filter     BeadsFilter        // active board filters (sticky form state)
	FilterOpts BeadsFilterOptions // distinct values for the filter dropdowns
	Milestones []BeadMilestone    // per-milestone progress rollup (empty if none)

	// detail / epic modes
	Issue        *BeadIssue
	DependsOn    []BeadEdge     // this issue depends on … (outgoing)
	DependedOnBy []BeadEdge     // … is depended on by this issue (incoming)
	DependsOn    []BeadEdge     // this issue depends on … (outgoing, direct)
	DependedOnBy []BeadEdge     // … is depended on by this issue (incoming, direct)
	Comments     []BeadComment  // the comment thread (Comments tab)
	History      []BeadActivity // comments + audit events, time-sorted (History tab)

	// Transitive dependency trees (flattened, pre-order with Depth), shown only
	// when they reach past the direct edges. DependsTree is the full prerequisite
	// chain; DependentTree is everything this issue transitively unblocks.
	DependsTree   []BeadTreeNode
	DependentTree []BeadTreeNode

	// epic mode: the issue's parent-child children and their rollup.
	Subtasks     []BeadSubtask
	SubtaskDone  int // # of subtasks in the closed category


@@ 94,12 101,13 @@ type BeadsFilter struct {
	Priority string // exact priority ("0".."3")
	Assignee string // exact assignee
	Label    string // issue must carry this label
	Ready    bool   // only actionable-now issues (bd's `ready` set)
}

// Active reports whether any filter is set (drives the "Clear" link and the
// empty-board wording).
func (f BeadsFilter) Active() bool {
	return f.Query != "" || f.Type != "" || f.Priority != "" || f.Assignee != "" || f.Label != ""
	return f.Query != "" || f.Type != "" || f.Priority != "" || f.Assignee != "" || f.Label != "" || f.Ready
}

// matches reports whether one issue row passes every set filter.


@@ 135,6 143,26 @@ type BeadsFilterOptions struct {
	Labels     []string
}

// milestonePrefix marks labels that name a milestone; the rollup groups by them.
const milestonePrefix = "milestone:"

// BeadMilestone is one milestone's progress rollup: the count of issues carrying
// its label and how many are closed.
type BeadMilestone struct {
	Name  string // label with the "milestone:" prefix stripped, for display
	Label string // full label, for the filter link
	Total int
	Done  int
}

// Pct is the milestone's completion percentage (0..100) for the progress bar.
func (m BeadMilestone) Pct() int {
	if m.Total == 0 {
		return 0
	}
	return m.Done * 100 / m.Total
}

// BeadsLane is one parade lane and the cards in it.
type BeadsLane struct {
	Name   string // human label, e.g. "Rolling"


@@ 160,8 188,9 @@ type BeadCard struct {
	Priority  string // as stored ("0".."3", ""); PriorityLabel derives the pill
	Assignee  string
	Labels    []string
	BlockedBy int // # of deps this issue has (things it waits on)
	Blocks    int // # of deps pointing at this issue (things waiting on it)
	BlockedBy int  // # of deps this issue has (things it waits on)
	Blocks    int  // # of deps pointing at this issue (things waiting on it)
	Ready     bool // actionable now: open, unblocked, not deferred/template (bd's `ready` set)
}

// PriorityLabel renders the numeric priority as a P-pill label ("P0".."P3"),


@@ 185,6 214,25 @@ type BeadEdge struct {
	Closed  bool
}

// BeadTreeNode is one node in a flattened transitive dependency tree. Depth is
// the indentation level (0 = a direct edge of the root issue); Type is the
// dependency type of the edge that reached this node.
type BeadTreeNode struct {
	ID     string
	Title  string
	Type   string
	Status string
	Closed bool
	Depth  int
}

// depTreeMaxDepth / depTreeMaxNodes bound the transitive walk so a dense or
// cyclic graph can never blow up a detail page.
const (
	depTreeMaxDepth = 6
	depTreeMaxNodes = 200
)

// BeadComment is one row of the comments thread.
type BeadComment struct {
	Author    string


@@ 326,8 374,10 @@ func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo,
		if to != "" {
			blocksCount[to]++
		}
		if from != "" && (typ == "blocks" || typ == "parent-child") {
			// Blocked only while the thing it waits on is not yet closed.
		if from != "" && typ == "blocks" {
			// A "blocks" edge to a still-open target blocks the source. parent-child
			// is hierarchy, not a blocker — a subtask is not blocked by its (open)
			// epic, matching bd's own is_blocked/ready accounting.
			if catByIssue[to] != "closed" {
				blockedOpen[from] = true
			}


@@ 361,8 411,10 @@ func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo,
		Priority: query.Get("priority"),
		Assignee: query.Get("assignee"),
		Label:    query.Get("label"),
		Ready:    query.Get("ready") == "1",
	}
	opts := collectFilterOptions(issues, issueCols, labelsByIssue)
	milestones := collectMilestones(labelsByIssue, catByIssue)

	// Bucket every matching issue into exactly one lane.
	var rolling, linedUp, stalled, pastStand []BeadCard


@@ 371,6 423,16 @@ func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo,
		if !filter.matches(id, r, issueCols, labelsByIssue[id]) {
			continue
		}
		cat := catByIssue[id]
		blocked := truthy(cell(issueCols, r, "is_blocked")) || blockedOpen[id]
		// "Ready" mirrors bd's ready set: open (not in-progress/closed), unblocked,
		// and not a template/ephemeral scaffold. Derived in-process (the issues
		// data is already loaded) rather than reading the full ready_issues table.
		ready := cat == "open" && !blocked &&
			!truthy(cell(issueCols, r, "is_template")) && !truthy(cell(issueCols, r, "ephemeral"))
		if filter.Ready && !ready {
			continue
		}
		card := BeadCard{
			ID:        id,
			Title:     cell(issueCols, r, "title"),


@@ 380,9 442,8 @@ func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo,
			Labels:    labelsByIssue[id],
			BlockedBy: blockedByCount[id],
			Blocks:    blocksCount[id],
			Ready:     ready,
		}
		cat := catByIssue[id]
		blocked := truthy(cell(issueCols, r, "is_blocked")) || blockedOpen[id]

		switch {
		case cat == "closed":


@@ 425,6 486,7 @@ func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo,
		ShownOf:    shownOf,
		Filter:     filter,
		FilterOpts: opts,
		Milestones: milestones,
	}
	return data, nil
}


@@ 547,6 609,28 @@ func (v *beadsView) buildDetail(
	}
	sortSubtasks(data.Subtasks)

	// Transitive dependency trees over the full edge set. Kept only when they
	// reach past the direct edges (a Depth>0 node), so they add the chain the
	// flat Depends-on / Depended-on-by lists can't show, without duplicating them.
	outAdj := map[string][]depLink{} // id → things it depends on
	inAdj := map[string][]depLink{}  // id → things that depend on it
	for _, r := range deps.Rows {
		from := cell(depCols, r, "issue_id")
		to := cell(depCols, r, "depends_on_issue_id")
		if from == "" || to == "" {
			continue
		}
		typ := cell(depCols, r, "type")
		outAdj[from] = append(outAdj[from], depLink{to: to, typ: typ})
		inAdj[to] = append(inAdj[to], depLink{to: from, typ: typ})
	}
	if t := buildDepTree(want, outAdj, titleByIssue, statusByIssue, catByStatus); hasTransitive(t) {
		data.DependsTree = t
	}
	if t := buildDepTree(want, inAdj, titleByIssue, statusByIssue, catByStatus); hasTransitive(t) {
		data.DependentTree = t
	}

	// Comments are optional; a missing table just yields an empty thread. Each
	// comment is also folded into the merged history timeline below.
	if comments, _, err := readRowsOptional(ctx, sess, ref, "comments"); err == nil && comments != nil {


@@ 823,6 907,49 @@ func collectFilterOptions(issues *browse.RowPage, cols map[string]int, labelsByI
	}
}

// collectMilestones rolls up progress per "milestone:<name>" label: how many
// issues carry it and how many are closed. Returns the milestones sorted by
// name, or nil when the DB uses no milestone labels.
func collectMilestones(labelsByIssue map[string][]string, catByIssue map[string]string) []BeadMilestone {
	type agg struct{ total, done int }
	byLabel := map[string]*agg{}
	for id, lbs := range labelsByIssue {
		for _, l := range lbs {
			if !strings.HasPrefix(l, milestonePrefix) {
				continue
			}
			a := byLabel[l]
			if a == nil {
				a = &agg{}
				byLabel[l] = a
			}
			a.total++
			if catByIssue[id] == "closed" {
				a.done++
			}
		}
	}
	if len(byLabel) == 0 {
		return nil
	}
	labels := make([]string, 0, len(byLabel))
	for l := range byLabel {
		labels = append(labels, l)
	}
	sort.Strings(labels)
	out := make([]BeadMilestone, 0, len(labels))
	for _, l := range labels {
		a := byLabel[l]
		out = append(out, BeadMilestone{
			Name:  strings.TrimPrefix(l, milestonePrefix),
			Label: l,
			Total: a.total,
			Done:  a.done,
		})
	}
	return out
}

// sortedKeys returns a set's keys in ascending order.
func sortedKeys(set map[string]bool) []string {
	out := make([]string, 0, len(set))


@@ 843,6 970,57 @@ func containsString(xs []string, s string) bool {
	return false
}

// depLink is one outgoing edge in a dependency adjacency map: the neighbor id
// and the edge's dependency type.
type depLink struct {
	to  string
	typ string
}

// buildDepTree walks the adjacency from root (exclusive) breadth-consistent
// pre-order, flattening the reachable set into indented nodes. Each issue
// appears once (first path wins); depth and node count are bounded so a dense
// or cyclic graph is safe.
func buildDepTree(root string, adj map[string][]depLink, titleOf, statusOf, catByStatus map[string]string) []BeadTreeNode {
	var out []BeadTreeNode
	visited := map[string]bool{root: true}
	var dfs func(id string, depth int)
	dfs = func(id string, depth int) {
		if depth > depTreeMaxDepth || len(out) >= depTreeMaxNodes {
			return
		}
		for _, lnk := range adj[id] {
			if visited[lnk.to] || len(out) >= depTreeMaxNodes {
				continue
			}
			visited[lnk.to] = true
			st := statusOf[lnk.to]
			out = append(out, BeadTreeNode{
				ID:     lnk.to,
				Title:  titleOf[lnk.to],
				Type:   lnk.typ,
				Status: st,
				Closed: statusCategory(st, catByStatus) == "closed",
				Depth:  depth,
			})
			dfs(lnk.to, depth+1)
		}
	}
	dfs(root, 0)
	return out
}

// hasTransitive reports whether a flattened tree reaches past the direct edges
// (any Depth>0 node) — the signal that it adds something the flat list doesn't.
func hasTransitive(nodes []BeadTreeNode) bool {
	for _, n := range nodes {
		if n.Depth > 0 {
			return true
		}
	}
	return false
}

// depActivity synthesizes a History entry for a dependency edge touching `want`.
// beads emits no audit event when a link is added, but the dependencies row
// carries created_at/created_by, so edge additions — most usefully subtasks

M web/beads_test.go => web/beads_test.go +126 -3
@@ 277,6 277,100 @@ func TestBeadsBoardFilterOptions(t *testing.T) {
	}
}

func TestBeadsReady(t *testing.T) {
	repo := &core.Repo{OwnerName: "a", Name: "b"}
	got, _ := (&beadsView{}).Build(context.Background(), beadsFixture(), repo, "main", url.Values{})
	d := got.(*BeadsData)

	ready := map[string]bool{}
	for i := range d.Lanes {
		for _, c := range d.Lanes[i].Issues {
			ready[c.ID] = c.Ready
		}
	}
	// Only i-open is actionable: open + unblocked. i-prog is in-progress, i-done
	// closed, i-blocked blocked.
	if !ready["i-open"] {
		t.Errorf("i-open should be ready")
	}
	for _, id := range []string{"i-prog", "i-done", "i-blocked"} {
		if ready[id] {
			t.Errorf("%s should not be ready", id)
		}
	}
	// The ready filter narrows the board to the actionable set.
	f, _ := (&beadsView{}).Build(context.Background(), beadsFixture(), repo, "main", url.Values{"ready": {"1"}})
	if ids := boardIDs(f.(*BeadsData)); strings.Join(ids, ",") != "i-open" {
		t.Errorf("ready filter board = %v, want [i-open]", ids)
	}
}

func TestCollectMilestones(t *testing.T) {
	labelsByIssue := map[string][]string{
		"a": {"milestone:m1", "backend"},
		"b": {"milestone:m1"},
		"c": {"milestone:m2"},
		"d": {"other"}, // non-milestone label ignored
	}
	cat := map[string]string{"a": "closed", "b": "open", "c": "open", "d": "open"}
	ms := collectMilestones(labelsByIssue, cat)
	if len(ms) != 2 {
		t.Fatalf("got %d milestones, want 2: %+v", len(ms), ms)
	}
	if ms[0].Name != "m1" || ms[0].Label != "milestone:m1" || ms[0].Total != 2 || ms[0].Done != 1 || ms[0].Pct() != 50 {
		t.Errorf("m1 rollup = %+v", ms[0])
	}
	if ms[1].Name != "m2" || ms[1].Total != 1 || ms[1].Done != 0 || ms[1].Pct() != 0 {
		t.Errorf("m2 rollup = %+v", ms[1])
	}
	// No milestone labels → nil.
	if collectMilestones(map[string][]string{"x": {"plain"}}, map[string]string{"x": "open"}) != nil {
		t.Errorf("expected nil when no milestone labels present")
	}
}

func TestBeadsDepTree(t *testing.T) {
	repo := &core.Repo{OwnerName: "a", Name: "b"}
	// Chain: a --blocks--> b --blocks--> c (c closed). a depends on b depends on c.
	sess := &fakeSession{
		tables: beadsTables(),
		rowsByTable: map[string]*browse.RowPage{
			"issues": {
				Columns: []string{"id", "title", "status"},
				Rows:    [][]string{{"a", "Aye", "open"}, {"b", "Bee", "open"}, {"c", "Cee", "closed"}},
				Total:   3,
			},
			"dependencies": {
				Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
				Rows:    [][]string{{"d1", "a", "b", "blocks"}, {"d2", "b", "c", "blocks"}},
				Total:   2,
			},
		},
	}
	// From a: transitive prerequisites b(0) → c(1); c is closed.
	got, _ := (&beadsView{}).Build(context.Background(), sess, repo, "main", url.Values{"issue": {"a"}})
	d := got.(*BeadsData)
	if len(d.DependsTree) != 2 {
		t.Fatalf("DependsTree = %+v, want [b(0) c(1)]", d.DependsTree)
	}
	if d.DependsTree[0].ID != "b" || d.DependsTree[0].Depth != 0 {
		t.Errorf("node0 = %+v, want b depth 0", d.DependsTree[0])
	}
	if d.DependsTree[1].ID != "c" || d.DependsTree[1].Depth != 1 || !d.DependsTree[1].Closed {
		t.Errorf("node1 = %+v, want c depth 1 closed", d.DependsTree[1])
	}

	// From the leaf c: transitive dependents b(0) → a(1); no prerequisites.
	gotC, _ := (&beadsView{}).Build(context.Background(), sess, repo, "main", url.Values{"issue": {"c"}})
	dc := gotC.(*BeadsData)
	if len(dc.DependentTree) != 2 || dc.DependentTree[0].ID != "b" || dc.DependentTree[1].ID != "a" {
		t.Fatalf("DependentTree = %+v, want [b(0) a(1)]", dc.DependentTree)
	}
	if len(dc.DependsTree) != 0 {
		t.Errorf("c has no prerequisites; DependsTree = %+v", dc.DependsTree)
	}
}

// TestBeadsBlockedByDepOnly proves the dependency-derived block signal works
// even when is_blocked is not set: an issue with a "blocks" dep to a non-closed
// target lands in Stalled; the same dep to a CLOSED target does not.


@@ 387,10 481,39 @@ func TestBeadsHandleViewBoard(t *testing.T) {
	if !strings.Contains(body, "/~alice/db/tree/") {
		t.Errorf("board missing Tables tab link; body=%s", body)
	}
	// The filter bar renders with option lists drawn from the data.
	for _, want := range []string{`class="beads-filter"`, "All types", ">feature<", "All priorities"} {
	// The filter bar renders with option lists drawn from the data, plus the
	// Ready-only toggle; i-open is ready, so a ready dot renders on the board.
	for _, want := range []string{`class="beads-filter"`, "All types", ">feature<", "All priorities", "Ready only", "ready-dot"} {
		if !strings.Contains(body, want) {
			t.Errorf("board missing control %q", want)
		}
	}
}

func TestBeadsDepTreeRender(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
	h.browse.sess = &fakeSession{
		branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
		tables:   beadsTables(),
		rowsByTable: map[string]*browse.RowPage{
			"issues": {Columns: []string{"id", "title", "status"},
				Rows: [][]string{{"a", "Aye", "open"}, {"b", "Bee", "open"}, {"c", "Cee", "open"}}, Total: 3},
			"dependencies": {Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
				Rows: [][]string{{"d1", "a", "b", "blocks"}, {"d2", "b", "c", "blocks"}}, Total: 2},
		},
	}
	setViews(t, h, &beadsView{})

	rec := h.do("GET", "/~alice/db/view/beads?issue=a", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("detail: got %d; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	// The transitive chain section appears with the depth-1 node c and an indent.
	for _, want := range []string{"Prerequisite chain", "dep-tree", "--depth: 1", ">c<"} {
		if !strings.Contains(body, want) {
			t.Errorf("board missing filter control %q", want)
			t.Errorf("dep-tree render missing %q; body=%s", want, body)
		}
	}
}

M web/realdata_test.go => web/realdata_test.go +20 -0
@@ 70,6 70,18 @@ func TestRealBeadsStore(t *testing.T) {
		filtered, _ := v.Build(ctx, dbh, nil, ref, url.Values{"type": {ft}})
		t.Logf("filter type=%q → %d issues (of %d)", ft, filtered.(*BeadsData).Total, bd.Total)
	}
	readyN := 0
	for i := range bd.Lanes {
		for _, c := range bd.Lanes[i].Issues {
			if c.Ready {
				readyN++
			}
		}
	}
	t.Logf("ready cards=%d; milestones:", readyN)
	for _, m := range bd.Milestones {
		t.Logf("  %-24s %d/%d (%d%%)", m.Name, m.Done, m.Total, m.Pct())
	}

	// Drive the detail view. With BEADS_REAL_ISSUE set to an epic id, dump the
	// subtask rollup and merged history so a human can eyeball the output.


@@ 95,4 107,12 @@ func TestRealBeadsStore(t *testing.T) {
	for _, a := range data.History {
		t.Logf("  %s | %s: %s %s | %s", a.CreatedAt, a.Kind, a.Actor, a.Summary, a.Text)
	}
	t.Logf("prerequisite chain (%d nodes):", len(data.DependsTree))
	for _, n := range data.DependsTree {
		t.Logf("  %*s%s %s [%s]", n.Depth*2, "", n.ID, n.Title, n.Status)
	}
	t.Logf("unblocks (%d nodes):", len(data.DependentTree))
	for _, n := range data.DependentTree {
		t.Logf("  %*s%s %s [%s]", n.Depth*2, "", n.ID, n.Title, n.Status)
	}
}

M web/templates/beads.html => web/templates/beads.html +68 -1
@@ 45,6 45,28 @@
.beads-filter input[type="search"] { min-width: 12rem; flex: 1 1 12rem; }
.beads-filter button { background: var(--bd-panel); cursor: pointer; }
.beads-filter .beads-filter-clear { font-size: .82rem; color: var(--bd-muted); align-self: center; }
.beads-ready-toggle { display: inline-flex; align-items: center; gap: .3rem; font-size: .82rem; color: var(--bd-fg); }
.ready-dot { margin-right: .25rem; }

/* milestone rollup: one flat row per milestone, a thin progress bar */
.beads-milestones { border: 1px solid var(--bd-border); padding: .5rem .7rem; margin-bottom: 1rem; }
.beads-milestones h4 { margin: 0 0 .4rem; font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; color: var(--bd-muted); }
.beads-milestones .ms-row { display: flex; align-items: center; gap: .6rem; padding: .18rem 0; color: var(--bd-fg); text-decoration: none; }
.beads-milestones .ms-name { flex: 0 0 12rem; font-size: .85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.beads-milestones .ms-bar { flex: 1 1 auto; height: 6px; background: var(--bd-panel); border: 1px solid var(--bd-border); }
.beads-milestones .ms-fill { display: block; height: 100%; background: var(--lane-linedup); }
.beads-milestones .ms-count { flex: 0 0 auto; font-size: .78rem; color: var(--bd-muted); min-width: 3rem; text-align: right; }
.beads-milestones .ms-row:hover .ms-name { text-decoration: underline; }

/* transitive dependency tree: indent by --depth via a hairline guide */
.dep-graph { margin: 0 0 1.25rem; }
.dep-tree { list-style: none; padding-left: 0; margin: 0 0 .5rem; }
.dep-tree .dep-node {
  padding: .18rem 0 .18rem calc(var(--depth, 0) * 1.1rem + .1rem);
  border-top: 1px solid var(--bd-border);
}
.dep-tree .dep-node:first-child { border-top: none; }
.dep-tree code { font-size: .8rem; }

.beads-summary { display: flex; flex-wrap: wrap; border: 1px solid var(--bd-border); margin-bottom: 1rem; }
.beads-summary .stat {


@@ 240,6 262,37 @@ pre.field-body {
  </div>
</div>

{{if or $.Data.DependsTree $.Data.DependentTree}}
<div class="dep-graph">
  {{if $.Data.DependsTree}}
  <h4>Prerequisite chain <small class="text-muted">— everything this waits on, transitively</small></h4>
  <ul class="dep-tree">
    {{range $.Data.DependsTree}}
    <li class="dep-node" style="--depth: {{.Depth}}">
      <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.ID | urlquery}}"><code>{{.ID}}</code></a>
      {{if .Title}}— {{.Title}}{{end}}
      <span class="blabel muted">{{.Type}}</span>
      {{if .Closed}}<span class="blabel muted">closed</span>{{else}}<span class="blabel block">{{.Status}}</span>{{end}}
    </li>
    {{end}}
  </ul>
  {{end}}
  {{if $.Data.DependentTree}}
  <h4>Unblocks <small class="text-muted">— everything waiting on this, transitively</small></h4>
  <ul class="dep-tree">
    {{range $.Data.DependentTree}}
    <li class="dep-node" style="--depth: {{.Depth}}">
      <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.ID | urlquery}}"><code>{{.ID}}</code></a>
      {{if .Title}}— {{.Title}}{{end}}
      <span class="blabel muted">{{.Type}}</span>
      {{if .Closed}}<span class="blabel muted">closed</span>{{else}}<span class="blabel block">{{.Status}}</span>{{end}}
    </li>
    {{end}}
  </ul>
  {{end}}
</div>
{{end}}

<div class="bead-activity">
  <input type="radio" name="acttab" id="acttab-comments" class="acttab-radio" checked>
  <input type="radio" name="acttab" id="acttab-history" class="acttab-radio">


@@ 310,10 363,24 @@ pre.field-body {
    <option value="">Any label</option>
    {{range .Data.FilterOpts.Labels}}<option value="{{.}}"{{if eq . $.Data.Filter.Label}} selected{{end}}>{{.}}</option>{{end}}
  </select>
  <label class="beads-ready-toggle"><input type="checkbox" name="ready" value="1"{{if .Data.Filter.Ready}} checked{{end}}> &#9889; Ready only</label>
  <button type="submit">Filter</button>
  {{if .Data.Filter.Active}}<a class="beads-filter-clear" href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads{{if .Ref}}?ref={{.Ref}}{{end}}">Clear</a>{{end}}
</form>

{{if .Data.Milestones}}
<div class="beads-milestones">
  <h4>Milestones</h4>
  {{range .Data.Milestones}}
  <a class="ms-row" href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?label={{.Label | urlquery}}">
    <span class="ms-name">{{.Name}}</span>
    <span class="ms-bar"><span class="ms-fill" style="width: {{.Pct}}%"></span></span>
    <span class="ms-count">{{.Done}}/{{.Total}}</span>
  </a>
  {{end}}
</div>
{{end}}

<div class="beads-summary">
  <div class="stat rolling"><div class="n">{{.Data.Counts.Rolling}}</div><div class="l"><span class="swatch"></span>Rolling</div></div>
  <div class="stat lined-up"><div class="n">{{.Data.Counts.LinedUp}}</div><div class="l"><span class="swatch"></span>Lined Up</div></div>


@@ 331,7 398,7 @@ pre.field-body {
    <div class="lane-body">
      {{range .Issues}}
      <a class="bead-row" href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.ID | urlquery}}">
        <span class="r-id">{{.ID}}</span>
        <span class="r-id">{{if .Ready}}<span class="ready-dot" title="Ready to work">&#9889;</span>{{end}}{{.ID}}</span>
        <span class="r-title">{{.Title}}</span>
        <span class="r-meta">
          {{if .PriorityLabel}}<span class="blabel prio">{{.PriorityLabel}}</span>{{end}}