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}}> ⚡ 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">⚡</span>{{end}}{{.ID}}</span>
<span class="r-title">{{.Title}}</span>
<span class="r-meta">
{{if .PriorityLabel}}<span class="blabel prio">{{.PriorityLabel}}</span>{{end}}