From 5c5479c4040cb4ba231cfe09ddae8634879ad5d9 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 19 Jul 2026 23:43:42 +0300 Subject: [PATCH] feat(web/beads): ready markers, milestone rollup, transitive dependency tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:" 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. --- web/beads.go | 196 +++++++++++++++++++++++++++++++++++++-- web/beads_test.go | 129 +++++++++++++++++++++++++- web/realdata_test.go | 20 ++++ web/templates/beads.html | 69 +++++++++++++- 4 files changed, 401 insertions(+), 13 deletions(-) diff --git a/web/beads.go b/web/beads.go index fc1e787a382932d00346c7a3606c73e20e1c1547..e0af8fafec7a5dba28e1950036cf0232b8864d56 100644 --- a/web/beads.go +++ b/web/beads.go @@ -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:" 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 diff --git a/web/beads_test.go b/web/beads_test.go index 5d3e6c28a03d19ce87b917625696302b8253d6c4..9c48893839d13dadcb572967cd50af8b0b542510 100644 --- a/web/beads_test.go +++ b/web/beads_test.go @@ -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) } } } diff --git a/web/realdata_test.go b/web/realdata_test.go index d68aa9d57b54352b5069f09d813d5763a77fd1b8..1171a1e1b18e617883b3aeaa189282b1313435a0 100644 --- a/web/realdata_test.go +++ b/web/realdata_test.go @@ -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) + } } diff --git a/web/templates/beads.html b/web/templates/beads.html index 36782b3f64331db4fa413831b33426a1dcd3872a..10c49df7c6e5457563ac3938089717290df21d0b 100644 --- a/web/templates/beads.html +++ b/web/templates/beads.html @@ -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 { +{{if or $.Data.DependsTree $.Data.DependentTree}} +
+ {{if $.Data.DependsTree}} +

Prerequisite chain — everything this waits on, transitively

+
    + {{range $.Data.DependsTree}} +
  • + {{.ID}} + {{if .Title}}— {{.Title}}{{end}} + {{.Type}} + {{if .Closed}}closed{{else}}{{.Status}}{{end}} +
  • + {{end}} +
+ {{end}} + {{if $.Data.DependentTree}} +

Unblocks — everything waiting on this, transitively

+
    + {{range $.Data.DependentTree}} +
  • + {{.ID}} + {{if .Title}}— {{.Title}}{{end}} + {{.Type}} + {{if .Closed}}closed{{else}}{{.Status}}{{end}} +
  • + {{end}} +
+ {{end}} +
+{{end}} +
@@ -310,10 +363,24 @@ pre.field-body { {{range .Data.FilterOpts.Labels}}{{end}} + {{if .Data.Filter.Active}}Clear{{end}} +{{if .Data.Milestones}} +
+

Milestones

+ {{range .Data.Milestones}} + + {{.Name}} + + {{.Done}}/{{.Total}} + + {{end}} +
+{{end}} +
{{.Data.Counts.Rolling}}
Rolling
{{.Data.Counts.LinedUp}}
Lined Up
@@ -331,7 +398,7 @@ pre.field-body {