~bigbes/sr-ht-dolt

8e89f8a025a3b0b6304a280de3ca1ab311daa911 — Eugene Blikh 29 days ago 5c5479c
feat(web/beads): promote milestones to their own view/tab

Move the milestone rollup off the board and into a dedicated "Milestones" tab
(a registered View, companion to Beads via the same fingerprint). The tab is
richer than the old board panel: per-milestone progress bar, done/open/in-progress
counts, and the issue list under each milestone (open-work first, closed struck
through), each linking to its beads detail. Issues with no milestone label are
summarized in a footnote. A beads DB with no milestone labels still gets the tab,
showing an empty-state hint.

Removes the board's inline milestone panel and the collectMilestones/BeadMilestone
helpers it used; the grouping now lives in the milestones view.
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{
		"&middot; 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> &middot; 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 &middot; {{.Open}} open{{if .InProgress}} &middot; {{.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:&lt;name&gt;</code> label to group them here.</p>
{{end}}
</div>
{{- end}}