M web/beads.go => web/beads.go +4 -69
@@ 71,7 71,6 @@ 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
@@ 143,26 142,6 @@ 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"
@@ 188,9 167,10 @@ 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)
- Ready bool // actionable now: open, unblocked, not deferred/template (bd's `ready` set)
+ 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)
+ Category string // open | in_progress | closed (used by the milestones view)
}
// PriorityLabel renders the numeric priority as a P-pill label ("P0".."P3"),
@@ 414,7 394,6 @@ func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo,
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
@@ 486,7 465,6 @@ func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo,
ShownOf: shownOf,
Filter: filter,
FilterOpts: opts,
- Milestones: milestones,
}
return data, nil
}
@@ 907,49 885,6 @@ 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))
M web/beads_test.go => web/beads_test.go +0 -24
@@ 305,30 305,6 @@ func TestBeadsReady(t *testing.T) {
}
}
-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.
A web/milestones.go => web/milestones.go +163 -0
@@ 0,0 1,163 @@
+package web
+
+import (
+ "context"
+ "net/url"
+ "sort"
+ "strings"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// milestonePrefix marks labels that name a milestone; the view groups by them.
+const milestonePrefix = "milestone:"
+
+// milestonesView groups a beads issue DB by its "milestone:<name>" labels and
+// shows per-milestone progress with the issues under each. It shares the beads
+// fingerprint, so it appears as a companion tab wherever the Beads view does.
+type milestonesView struct{}
+
+func init() { RegisterView(&milestonesView{}) }
+
+func (*milestonesView) Name() string { return "milestones" }
+func (*milestonesView) Label() string { return "Milestones" }
+func (*milestonesView) Template() string { return "milestones.html" }
+
+// Applies mirrors the beads fingerprint so Milestones and Beads pair up. A
+// beads DB that happens to use no milestone labels still gets the tab; it just
+// renders an empty state.
+func (*milestonesView) Applies(tables []browse.TableInfo) bool {
+ return (&beadsView{}).Applies(tables)
+}
+
+// MilestoneView is the opaque .Data handed to milestones.html.
+type MilestoneView struct {
+ Milestones []MilestoneDetail
+ Unlabeled int // issues carrying no milestone label
+ Total int // all issues read
+}
+
+// MilestoneDetail is one milestone's rollup and the issues under it.
+type MilestoneDetail struct {
+ Name string // label with the "milestone:" prefix stripped
+ Label string // full label, for filter links back to the board
+ Total int
+ Done int // closed
+ InProgress int
+ Open int // open (or unknown) — the remaining work
+ Issues []BeadCard
+}
+
+// Pct is the milestone's completion percentage (0..100) for the progress bar.
+func (m MilestoneDetail) Pct() int {
+ if m.Total == 0 {
+ return 0
+ }
+ return m.Done * 100 / m.Total
+}
+
+// Build reads the issues and their labels, then groups by milestone label. An
+// issue with several milestone labels counts under each. Missing labels/statuses
+// tables degrade to empty (no milestones), never an error.
+func (v *milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, _ url.Values) (any, error) {
+ issues, _, err := readRows(ctx, sess, ref, "issues")
+ if err != nil {
+ return nil, err
+ }
+ labels, _, _ := readRowsOptional(ctx, sess, ref, "labels")
+ statuses, _, _ := readRowsOptional(ctx, sess, ref, "custom_statuses")
+
+ catByStatus := map[string]string{}
+ if statuses != nil {
+ cols := indexCols(statuses.Columns)
+ for _, r := range statuses.Rows {
+ if name := cell(cols, r, "name"); name != "" {
+ catByStatus[strings.ToLower(name)] = strings.ToLower(cell(cols, r, "category"))
+ }
+ }
+ }
+
+ labelsByIssue := map[string][]string{}
+ if labels != nil {
+ cols := indexCols(labels.Columns)
+ for _, r := range labels.Rows {
+ id := cell(cols, r, "issue_id")
+ lb := cell(cols, r, "label")
+ if id != "" && lb != "" {
+ labelsByIssue[id] = append(labelsByIssue[id], lb)
+ }
+ }
+ }
+
+ issueCols := indexCols(issues.Columns)
+ byLabel := map[string]*MilestoneDetail{}
+ unlabeled := 0
+ for _, r := range issues.Rows {
+ id := cell(issueCols, r, "id")
+ cat := statusCategory(cell(issueCols, r, "status"), catByStatus)
+ card := BeadCard{
+ ID: id,
+ Title: cell(issueCols, r, "title"),
+ Type: cell(issueCols, r, "issue_type"),
+ Priority: cell(issueCols, r, "priority"),
+ Assignee: cell(issueCols, r, "assignee"),
+ Category: cat,
+ }
+ seen := false
+ for _, l := range labelsByIssue[id] {
+ if !strings.HasPrefix(l, milestonePrefix) {
+ continue
+ }
+ seen = true
+ md := byLabel[l]
+ if md == nil {
+ md = &MilestoneDetail{Name: strings.TrimPrefix(l, milestonePrefix), Label: l}
+ byLabel[l] = md
+ }
+ md.Total++
+ switch cat {
+ case "closed":
+ md.Done++
+ case "in_progress":
+ md.InProgress++
+ default:
+ md.Open++
+ }
+ md.Issues = append(md.Issues, card)
+ }
+ if !seen {
+ unlabeled++
+ }
+ }
+
+ names := make([]string, 0, len(byLabel))
+ for l := range byLabel {
+ names = append(names, l)
+ }
+ sort.Strings(names)
+ out := make([]MilestoneDetail, 0, len(names))
+ for _, l := range names {
+ md := byLabel[l]
+ sortMilestoneCards(md.Issues)
+ out = append(out, *md)
+ }
+
+ return &MilestoneView{Milestones: out, Unlabeled: unlabeled, Total: len(issues.Rows)}, nil
+}
+
+// sortMilestoneCards orders a milestone's issues open-work-first (closed sinks to
+// the bottom), then by priority, then id — a stable, deterministic order.
+func sortMilestoneCards(cards []BeadCard) {
+ sort.SliceStable(cards, func(i, j int) bool {
+ ci, cj := cards[i].Category == "closed", cards[j].Category == "closed"
+ if ci != cj {
+ return !ci
+ }
+ pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority)
+ if pi != pj {
+ return pi < pj
+ }
+ return cards[i].ID < cards[j].ID
+ })
+}
A web/milestones_test.go => web/milestones_test.go +133 -0
@@ 0,0 1,133 @@
+package web
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// milestoneFixture: four issues across two milestones (m1 has one closed), one
+// issue with no milestone label.
+func milestoneFixture() *fakeSession {
+ issues := &browse.RowPage{
+ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee"},
+ Rows: [][]string{
+ {"i-a", "Alpha", "closed", "1", "task", "alice"},
+ {"i-b", "Bravo", "open", "0", "bug", "bob"},
+ {"i-c", "Charlie", "open", "2", "feature", ""},
+ {"i-d", "Delta", "open", "1", "task", ""}, // no milestone
+ },
+ Total: 4,
+ }
+ labels := &browse.RowPage{
+ Columns: []string{"issue_id", "label"},
+ Rows: [][]string{
+ {"i-a", "milestone:m1"},
+ {"i-b", "milestone:m1"},
+ {"i-c", "milestone:m2"},
+ {"i-b", "backend"}, // non-milestone label ignored by grouping
+ },
+ Total: 4,
+ }
+ statuses := &browse.RowPage{
+ Columns: []string{"name", "category"},
+ Rows: [][]string{{"open", "open"}, {"closed", "closed"}},
+ Total: 2,
+ }
+ return &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
+ tables: beadsTables(),
+ rowsByTable: map[string]*browse.RowPage{
+ "issues": issues,
+ "labels": labels,
+ "custom_statuses": statuses,
+ },
+ }
+}
+
+func TestMilestonesApplies(t *testing.T) {
+ // The milestone tab appears exactly where the beads tab does.
+ if (&milestonesView{}).Applies(beadsTables()) != (&beadsView{}).Applies(beadsTables()) {
+ t.Fatalf("milestones Applies should mirror beads Applies")
+ }
+ if (&milestonesView{}).Applies([]browse.TableInfo{{Name: "widgets"}}) {
+ t.Fatalf("milestones should not apply to an unrelated schema")
+ }
+}
+
+func TestMilestonesBuild(t *testing.T) {
+ got, err := (&milestonesView{}).Build(context.Background(), milestoneFixture(), nil, "main", url.Values{})
+ if err != nil {
+ t.Fatalf("Build: %v", err)
+ }
+ d := got.(*MilestoneView)
+
+ if len(d.Milestones) != 2 {
+ t.Fatalf("milestones = %d, want 2: %+v", len(d.Milestones), d.Milestones)
+ }
+ if d.Unlabeled != 1 || d.Total != 4 {
+ t.Errorf("unlabeled=%d total=%d, want 1 and 4", d.Unlabeled, d.Total)
+ }
+ m1 := d.Milestones[0]
+ if m1.Name != "m1" || m1.Total != 2 || m1.Done != 1 || m1.Open != 1 || m1.Pct() != 50 {
+ t.Errorf("m1 = %+v, want name m1 total 2 done 1 open 1 pct 50", m1)
+ }
+ // Issues under m1, open-first: i-b (open) before i-a (closed).
+ if len(m1.Issues) != 2 || m1.Issues[0].ID != "i-b" || m1.Issues[1].ID != "i-a" {
+ t.Errorf("m1 issue order = %+v, want [i-b i-a]", m1.Issues)
+ }
+ if m1.Issues[1].Category != "closed" {
+ t.Errorf("i-a should be closed: %+v", m1.Issues[1])
+ }
+ m2 := d.Milestones[1]
+ if m2.Name != "m2" || m2.Total != 1 || m2.Done != 0 {
+ t.Errorf("m2 = %+v", m2)
+ }
+}
+
+func TestMilestonesRender(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 = milestoneFixture()
+ setViews(t, h, &beadsView{}, &milestonesView{})
+
+ rec := h.do("GET", "/~alice/db/view/milestones", nil, nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("milestones view: got %d; body=%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ for _, want := range []string{
+ "· milestones", // page heading
+ "ms-title\">m1<", // milestone name
+ "1</b>/2 done", // rollup counts
+ "Charlie", // an issue under m2
+ "carry no milestone", // unlabeled footnote
+ `view/milestones">Milestones`, // the tab link
+ "nav-link active", // the active tab marker
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("milestones render missing %q", want)
+ }
+ }
+}
+
+// TestMilestonesEmpty: a beads DB with no milestone labels still gets the tab,
+// showing an empty state rather than erroring.
+func TestMilestonesEmpty(t *testing.T) {
+ got, err := (&milestonesView{}).Build(context.Background(), beadsFixture(), nil, "main", url.Values{})
+ if err != nil {
+ t.Fatalf("Build: %v", err)
+ }
+ d := got.(*MilestoneView)
+ if len(d.Milestones) != 0 {
+ t.Errorf("expected no milestones, got %+v", d.Milestones)
+ }
+ if d.Unlabeled != d.Total {
+ t.Errorf("with no milestone labels all issues are unlabeled; got %d/%d", d.Unlabeled, d.Total)
+ }
+}
M web/realdata_test.go => web/realdata_test.go +11 -3
@@ 78,9 78,17 @@ func TestRealBeadsStore(t *testing.T) {
}
}
}
- 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())
+ t.Logf("ready cards=%d", readyN)
+
+ // Milestones view.
+ msRaw, err := (&milestonesView{}).Build(ctx, dbh, nil, ref, url.Values{})
+ if err != nil {
+ t.Fatalf("milestones build: %v", err)
+ }
+ ms := msRaw.(*MilestoneView)
+ t.Logf("milestones (%d, %d unlabeled of %d):", len(ms.Milestones), ms.Unlabeled, ms.Total)
+ for _, m := range ms.Milestones {
+ t.Logf(" %-24s %d/%d done (%d%%), %d issues", m.Name, m.Done, m.Total, m.Pct(), len(m.Issues))
}
// Drive the detail view. With BEADS_REAL_ISSUE set to an epic id, dump the
M web/templates/beads.html => web/templates/beads.html +0 -23
@@ 48,16 48,6 @@
.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; }
@@ 368,19 358,6 @@ pre.field-body {
{{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>
A web/templates/milestones.html => web/templates/milestones.html +76 -0
@@ 0,0 1,76 @@
+{{define "content" -}}
+<style>
+/* Self-contained milestone styles, inlined in the same flat todo.sr.ht idiom as
+ the beads view (the scss bundle is not rebuilt in dev). Theme variables mirror
+ core.sr.ht's palette and follow the host's light/dark preference. */
+.milestones {
+ --accent: #2f9e44;
+ --bd-bg: #ffffff; --bd-panel: #f2f3f5; --bd-fg: #212529;
+ --bd-muted: #6c757d; --bd-border: #ced4da; --bd-danger: #b52a2a;
+}
+@media (prefers-color-scheme: dark) {
+ .milestones {
+ --bd-bg: #212529; --bd-panel: #343a40; --bd-fg: #dee2e6;
+ --bd-muted: #adb5bd; --bd-border: #6c757d; --bd-danger: #ff6b6b;
+ }
+}
+.milestones .ms { border: 1px solid var(--bd-border); margin-bottom: 1rem; }
+.milestones .ms-head { display: flex; align-items: center; gap: .7rem; padding: .5rem .7rem; background: var(--bd-panel); border-bottom: 1px solid var(--bd-border); }
+.milestones .ms-title { font-weight: 700; font-size: 1rem; margin: 0; flex: 0 0 auto; }
+.milestones .ms-bar { flex: 1 1 auto; height: 8px; background: var(--bd-bg); border: 1px solid var(--bd-border); min-width: 6rem; }
+.milestones .ms-fill { display: block; height: 100%; background: var(--accent); }
+.milestones .ms-counts { flex: 0 0 auto; font-size: .8rem; color: var(--bd-muted); white-space: nowrap; }
+.milestones .ms-counts b { color: var(--bd-fg); }
+
+.milestones .ms-issue { display: flex; align-items: baseline; gap: .5rem; padding: .3rem .7rem; border-top: 1px solid var(--bd-border); text-decoration: none; color: var(--bd-fg); }
+.milestones .ms-issue:first-of-type { border-top: none; }
+.milestones .ms-issue:hover { background: var(--bd-panel); }
+.milestones .ms-issue .r-id { font-family: monospace; font-size: .8rem; color: var(--bd-muted); flex: 0 0 auto; }
+.milestones .ms-issue .r-title { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.milestones .ms-issue.done .r-title { text-decoration: line-through; color: var(--bd-muted); }
+.milestones .ms-issue .r-meta { flex: 0 0 auto; display: flex; gap: .3rem; }
+
+.milestones .blabel {
+ display: inline-block; font-size: .72rem; line-height: 1.4; padding: 0 .35rem;
+ border: 1px solid var(--bd-border); color: var(--bd-fg); white-space: nowrap;
+}
+.milestones .blabel.prio { border-color: var(--bd-danger); color: var(--bd-danger); }
+.milestones .blabel.muted { color: var(--bd-muted); }
+.milestones .blabel.assignee { border-color: var(--accent); }
+
+.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> · milestones</h2>
+{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "milestones" "Ref" .Ref)}}
+
+{{if .Data.Milestones}}
+{{range .Data.Milestones}}
+<div class="ms">
+ <div class="ms-head">
+ <h3 class="ms-title">{{.Name}}</h3>
+ <span class="ms-bar" title="{{.Pct}}% complete"><span class="ms-fill" style="width: {{.Pct}}%"></span></span>
+ <span class="ms-counts"><b>{{.Done}}</b>/{{.Total}} done · {{.Open}} open{{if .InProgress}} · {{.InProgress}} in progress{{end}}</span>
+ </div>
+ {{range .Issues}}
+ <a class="ms-issue {{if eq .Category "closed"}}done{{end}}" href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.ID | urlquery}}">
+ <span class="r-id">{{.ID}}</span>
+ <span class="r-title">{{.Title}}</span>
+ <span class="r-meta">
+ {{if .PriorityLabel}}<span class="blabel prio">{{.PriorityLabel}}</span>{{end}}
+ {{if .Type}}<span class="blabel">{{.Type}}</span>{{end}}
+ {{if .Assignee}}<span class="blabel assignee">@{{.Assignee}}</span>{{end}}
+ <span class="blabel muted">{{.Category}}</span>
+ </span>
+ </a>
+ {{end}}
+</div>
+{{end}}
+{{if .Data.Unlabeled}}<p class="ms-foot">{{.Data.Unlabeled}} of {{.Data.Total}} issues carry no milestone label.</p>{{end}}
+{{else}}
+<p class="ms-empty">No milestones. Tag issues with a <code>milestone:<name></code> label to group them here.</p>
+{{end}}
+</div>
+{{- end}}