From 8e89f8a025a3b0b6304a280de3ca1ab311daa911 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Mon, 20 Jul 2026 01:16:56 +0300 Subject: [PATCH] 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. --- web/beads.go | 73 +-------------- web/beads_test.go | 24 ----- web/milestones.go | 163 ++++++++++++++++++++++++++++++++++ web/milestones_test.go | 133 +++++++++++++++++++++++++++ web/realdata_test.go | 14 ++- web/templates/beads.html | 23 ----- web/templates/milestones.html | 76 ++++++++++++++++ 7 files changed, 387 insertions(+), 119 deletions(-) create mode 100644 web/milestones.go create mode 100644 web/milestones_test.go create mode 100644 web/templates/milestones.html diff --git a/web/beads.go b/web/beads.go index e0af8fafec7a5dba28e1950036cf0232b8864d56..212c320d421a31bdb7d92bc521be3f6c963d9eeb 100644 --- a/web/beads.go +++ b/web/beads.go @@ -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:" 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)) diff --git a/web/beads_test.go b/web/beads_test.go index 9c48893839d13dadcb572967cd50af8b0b542510..aea87e9187c96d70790813b6c8dae0d72a075ab8 100644 --- a/web/beads_test.go +++ b/web/beads_test.go @@ -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. diff --git a/web/milestones.go b/web/milestones.go new file mode 100644 index 0000000000000000000000000000000000000000..5508455bf33dea90f42dd5caa9f908f889b75275 --- /dev/null +++ b/web/milestones.go @@ -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:" 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 + }) +} diff --git a/web/milestones_test.go b/web/milestones_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3db8ef6bd91f8e06c69c251eacf2c99060f8b4fb --- /dev/null +++ b/web/milestones_test.go @@ -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/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) + } +} diff --git a/web/realdata_test.go b/web/realdata_test.go index 1171a1e1b18e617883b3aeaa189282b1313435a0..c01af875b8769cc08400aedf25f38e6c9eec109c 100644 --- a/web/realdata_test.go +++ b/web/realdata_test.go @@ -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 diff --git a/web/templates/beads.html b/web/templates/beads.html index 10c49df7c6e5457563ac3938089717290df21d0b..ac6cd0754a32090a6ffb9874cffe5bf39fef5ccf 100644 --- a/web/templates/beads.html +++ b/web/templates/beads.html @@ -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}}Clear{{end}} -{{if .Data.Milestones}} -
-

Milestones

- {{range .Data.Milestones}} - - {{.Name}} - - {{.Done}}/{{.Total}} - - {{end}} -
-{{end}} -
{{.Data.Counts.Rolling}}
Rolling
{{.Data.Counts.LinedUp}}
Lined Up
diff --git a/web/templates/milestones.html b/web/templates/milestones.html new file mode 100644 index 0000000000000000000000000000000000000000..b616603fba9109daa044a3d32a9c53fac7d9d443 --- /dev/null +++ b/web/templates/milestones.html @@ -0,0 +1,76 @@ +{{define "content" -}} + + +
+

~{{.Repo.OwnerName}}/{{.Repo.Name}} · milestones

+{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "milestones" "Ref" .Ref)}} + +{{if .Data.Milestones}} +{{range .Data.Milestones}} +
+
+

{{.Name}}

+ + {{.Done}}/{{.Total}} done · {{.Open}} open{{if .InProgress}} · {{.InProgress}} in progress{{end}} +
+ {{range .Issues}} + + {{.ID}} + {{.Title}} + + {{if .PriorityLabel}}{{.PriorityLabel}}{{end}} + {{if .Type}}{{.Type}}{{end}} + {{if .Assignee}}@{{.Assignee}}{{end}} + {{.Category}} + + + {{end}} +
+{{end}} +{{if .Data.Unlabeled}}

{{.Data.Unlabeled}} of {{.Data.Total}} issues carry no milestone label.

{{end}} +{{else}} +

No milestones. Tag issues with a milestone:<name> label to group them here.

+{{end}} +
+{{- end}}