From 8527f0fdd75341f817d55c259583dd1dbebe173f Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Wed, 5 Aug 2026 12:02:05 +0300 Subject: [PATCH] feat(web/beads): hierarchy in the milestone view Arrange each milestone as a shallow hierarchy instead of a flat list. - Put the milestone-typed issue(s) on top as the milestone's heads. - Nest tasks under their epics via parent-child dependency edges, with a done/total rollup on the epic row; membership stays label-based. - Keep remaining members as a loose tail; blocks edges are ignored. - Read the dependencies table optionally, degrading to a flat list. - Extend fixtures and real-data logging to cover the hierarchy. --- web/milestones.go | 114 +++++++++++++++++++++++++++++----- web/milestones_test.go | 74 +++++++++++++++------- web/realdata_test.go | 6 +- web/templates/milestones.html | 38 +++++++++--- 4 files changed, 188 insertions(+), 44 deletions(-) diff --git a/web/milestones.go b/web/milestones.go index 5508455bf33dea90f42dd5caa9f908f889b75275..eb8d5a285bb7524469b4917df9f1e685d088d3fc 100644 --- a/web/milestones.go +++ b/web/milestones.go @@ -38,15 +38,28 @@ type MilestoneView struct { Total int // all issues read } -// MilestoneDetail is one milestone's rollup and the issues under it. +// MilestoneDetail is one milestone's rollup and the issues under it, arranged +// as a shallow hierarchy: the milestone-typed issue(s) first, then epics with +// their subtasks nested one level below, then everything else. 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 + Open int // open (or unknown) — the remaining work + Heads []BeadCard // issue_type == "milestone" — the milestone's own issue(s) + Epics []MilestoneEpic // epics in the milestone, each with its nested subtasks + Loose []BeadCard // members that are neither heads, epics, nor nested subtasks +} + +// MilestoneEpic is an epic inside a milestone together with the milestone +// members nested under it (parent-child edges pointing at the epic). +type MilestoneEpic struct { + Card BeadCard + Done int // closed children, for the "d/t" rollup on the epic row + Total int + Children []BeadCard } // Pct is the milestone's completion percentage (0..100) for the progress bar. @@ -67,6 +80,23 @@ func (v *milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core. } labels, _, _ := readRowsOptional(ctx, sess, ref, "labels") statuses, _, _ := readRowsOptional(ctx, sess, ref, "custom_statuses") + deps, _, _ := readRowsOptional(ctx, sess, ref, "dependencies") + + // child issue → its parent-child parents; used to nest tasks under epics. + parentsByChild := map[string][]string{} + if deps != nil { + cols := indexCols(deps.Columns) + for _, r := range deps.Rows { + if !strings.EqualFold(cell(cols, r, "type"), "parent-child") { + continue + } + child := cell(cols, r, "issue_id") + parent := cell(cols, r, "depends_on_issue_id") + if child != "" && parent != "" { + parentsByChild[child] = append(parentsByChild[child], parent) + } + } + } catByStatus := map[string]string{} if statuses != nil { @@ -92,6 +122,7 @@ func (v *milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core. issueCols := indexCols(issues.Columns) byLabel := map[string]*MilestoneDetail{} + cardsByLabel := map[string][]BeadCard{} unlabeled := 0 for _, r := range issues.Rows { id := cell(issueCols, r, "id") @@ -124,7 +155,7 @@ func (v *milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core. default: md.Open++ } - md.Issues = append(md.Issues, card) + cardsByLabel[l] = append(cardsByLabel[l], card) } if !seen { unlabeled++ @@ -139,25 +170,80 @@ func (v *milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core. out := make([]MilestoneDetail, 0, len(names)) for _, l := range names { md := byLabel[l] - sortMilestoneCards(md.Issues) + md.arrange(cardsByLabel[l], parentsByChild) out = append(out, *md) } return &MilestoneView{Milestones: out, Unlabeled: unlabeled, Total: len(issues.Rows)}, nil } +// arrange splits a milestone's member cards into the display hierarchy: heads +// (issue_type "milestone") on top, then epics with the members parent-child'ed +// under them, then the leftovers. Only members of this milestone participate — +// membership stays purely label-based; a child nests only when its epic carries +// the same milestone label. +func (md *MilestoneDetail) arrange(cards []BeadCard, parentsByChild map[string][]string) { + epicByID := map[string]*MilestoneEpic{} + var epics []*MilestoneEpic + for _, c := range cards { + switch { + case strings.EqualFold(c.Type, "milestone"): + md.Heads = append(md.Heads, c) + case strings.EqualFold(c.Type, "epic"): + e := &MilestoneEpic{Card: c} + epicByID[c.ID] = e + epics = append(epics, e) + } + } + for _, c := range cards { + if strings.EqualFold(c.Type, "milestone") || strings.EqualFold(c.Type, "epic") { + continue + } + var home *MilestoneEpic + for _, p := range parentsByChild[c.ID] { + if e := epicByID[p]; e != nil { + home = e + break + } + } + if home == nil { + md.Loose = append(md.Loose, c) + continue + } + home.Total++ + if c.Category == "closed" { + home.Done++ + } + home.Children = append(home.Children, c) + } + + sortMilestoneCards(md.Heads) + sortMilestoneCards(md.Loose) + sort.SliceStable(epics, func(i, j int) bool { + return milestoneCardLess(epics[i].Card, epics[j].Card) + }) + for _, e := range epics { + sortMilestoneCards(e.Children) + md.Epics = append(md.Epics, *e) + } +} + // 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 + return milestoneCardLess(cards[i], cards[j]) }) } + +func milestoneCardLess(a, b BeadCard) bool { + ca, cb := a.Category == "closed", b.Category == "closed" + if ca != cb { + return !ca + } + pa, pb := priorityRank(a.Priority), priorityRank(b.Priority) + if pa != pb { + return pa < pb + } + return a.ID < b.ID +} diff --git a/web/milestones_test.go b/web/milestones_test.go index 3db8ef6bd91f8e06c69c251eacf2c99060f8b4fb..fc5f4c8e8aa4a544e10691985ef99554658e285e 100644 --- a/web/milestones_test.go +++ b/web/milestones_test.go @@ -11,28 +11,41 @@ import ( "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) -// milestoneFixture: four issues across two milestones (m1 has one closed), one -// issue with no milestone label. +// milestoneFixture: six issues across two milestones. m1 carries the full +// hierarchy — a milestone-typed head, an epic with one (closed) subtask, and a +// loose bug; m2 has a single loose feature; one issue has 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-m", "Mike", "open", "0", "milestone", ""}, + {"i-e", "Echo", "open", "1", "epic", ""}, + {"i-a", "Alpha", "closed", "1", "task", "alice"}, // subtask of i-e {"i-b", "Bravo", "open", "0", "bug", "bob"}, {"i-c", "Charlie", "open", "2", "feature", ""}, {"i-d", "Delta", "open", "1", "task", ""}, // no milestone }, - Total: 4, + Total: 6, } labels := &browse.RowPage{ Columns: []string{"issue_id", "label"}, Rows: [][]string{ + {"i-m", "milestone:m1"}, + {"i-e", "milestone:m1"}, {"i-a", "milestone:m1"}, {"i-b", "milestone:m1"}, {"i-c", "milestone:m2"}, {"i-b", "backend"}, // non-milestone label ignored by grouping }, - Total: 4, + Total: 6, + } + deps := &browse.RowPage{ + Columns: []string{"issue_id", "depends_on_issue_id", "type"}, + Rows: [][]string{ + {"i-a", "i-e", "parent-child"}, + {"i-b", "i-a", "blocks"}, // non-hierarchy edge ignored by nesting + }, + Total: 2, } statuses := &browse.RowPage{ Columns: []string{"name", "category"}, @@ -45,6 +58,7 @@ func milestoneFixture() *fakeSession { rowsByTable: map[string]*browse.RowPage{ "issues": issues, "labels": labels, + "dependencies": deps, "custom_statuses": statuses, }, } @@ -70,24 +84,38 @@ func TestMilestonesBuild(t *testing.T) { 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) + if d.Unlabeled != 1 || d.Total != 6 { + t.Errorf("unlabeled=%d total=%d, want 1 and 6", 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) + if m1.Name != "m1" || m1.Total != 4 || m1.Done != 1 || m1.Open != 3 || m1.Pct() != 25 { + t.Errorf("m1 = %+v, want name m1 total 4 done 1 open 3 pct 25", m1) + } + // Hierarchy: the milestone-typed issue heads the list, the epic nests its + // subtask, and the bug (whose only dep edge is "blocks") stays loose. + if len(m1.Heads) != 1 || m1.Heads[0].ID != "i-m" { + t.Errorf("m1 heads = %+v, want [i-m]", m1.Heads) } - // 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 len(m1.Epics) != 1 || m1.Epics[0].Card.ID != "i-e" { + t.Fatalf("m1 epics = %+v, want [i-e]", m1.Epics) } - if m1.Issues[1].Category != "closed" { - t.Errorf("i-a should be closed: %+v", m1.Issues[1]) + epic := m1.Epics[0] + if len(epic.Children) != 1 || epic.Children[0].ID != "i-a" || epic.Done != 1 || epic.Total != 1 { + t.Errorf("epic = %+v, want child i-a and rollup 1/1", epic) + } + if epic.Children[0].Category != "closed" { + t.Errorf("i-a should be closed: %+v", epic.Children[0]) + } + if len(m1.Loose) != 1 || m1.Loose[0].ID != "i-b" { + t.Errorf("m1 loose = %+v, want [i-b]", m1.Loose) } m2 := d.Milestones[1] if m2.Name != "m2" || m2.Total != 1 || m2.Done != 0 { t.Errorf("m2 = %+v", m2) } + if len(m2.Heads) != 0 || len(m2.Epics) != 0 || len(m2.Loose) != 1 || m2.Loose[0].ID != "i-c" { + t.Errorf("m2 hierarchy = %+v, want only loose [i-c]", m2) + } } func TestMilestonesRender(t *testing.T) { @@ -102,13 +130,17 @@ func TestMilestonesRender(t *testing.T) { } 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 + "· milestones", // page heading + "ms-title\">m1<", // milestone name + "1/4 done", // rollup counts + "ms-issue head", // the milestone-typed issue leads the list + "ms-issue epic", // the epic row + "ms-issue child done", // its subtask, nested and closed + `class="r-sub">1/1<`, // the epic's subtask rollup + "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) diff --git a/web/realdata_test.go b/web/realdata_test.go index c01af875b8769cc08400aedf25f38e6c9eec109c..20c6bef955e050f19f975f65203f0a282d0274f9 100644 --- a/web/realdata_test.go +++ b/web/realdata_test.go @@ -88,7 +88,11 @@ func TestRealBeadsStore(t *testing.T) { 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)) + t.Logf(" %-24s %d/%d done (%d%%), %d heads, %d epics, %d loose", + m.Name, m.Done, m.Total, m.Pct(), len(m.Heads), len(m.Epics), len(m.Loose)) + for _, e := range m.Epics { + t.Logf(" epic %s %d/%d: %s", e.Card.ID, e.Done, e.Total, e.Card.Title) + } } // Drive the detail view. With BEADS_REAL_ISSUE set to an epic id, dump the diff --git a/web/templates/milestones.html b/web/templates/milestones.html index b616603fba9109daa044a3d32a9c53fac7d9d443..96d977bb5f4c62537a7c33dd8df9d051e809fe5f 100644 --- a/web/templates/milestones.html +++ b/web/templates/milestones.html @@ -29,6 +29,11 @@ .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 .ms-issue.head { border-left: 3px solid var(--accent); background: var(--bd-panel); } +.milestones .ms-issue.head .r-title { font-weight: 700; } +.milestones .ms-issue.epic .r-title { font-weight: 600; } +.milestones .ms-issue.epic .r-sub { flex: 0 0 auto; font-size: .78rem; color: var(--bd-muted); } +.milestones .ms-issue.child { padding-left: 2.4rem; } .milestones .blabel { display: inline-block; font-size: .72rem; line-height: 1.4; padding: 0 .35rem; @@ -54,18 +59,22 @@ {{.Done}}/{{.Total}} done · {{.Open}} open{{if .InProgress}} · {{.InProgress}} in progress{{end}} - {{range .Issues}} - - {{.ID}} - {{.Title}} + {{range .Heads}}{{template "ms-row" (dict "Repo" $.Repo "Card" . "Class" "head")}}{{end}} + {{range .Epics}} + + {{.Card.ID}} + {{.Card.Title}} + {{if .Total}}{{.Done}}/{{.Total}}{{end}} - {{if .PriorityLabel}}{{.PriorityLabel}}{{end}} - {{if .Type}}{{.Type}}{{end}} - {{if .Assignee}}@{{.Assignee}}{{end}} - {{.Category}} + {{if .Card.PriorityLabel}}{{.Card.PriorityLabel}}{{end}} + epic + {{if .Card.Assignee}}@{{.Card.Assignee}}{{end}} + {{.Card.Category}} + {{range .Children}}{{template "ms-row" (dict "Repo" $.Repo "Card" . "Class" "child")}}{{end}} {{end}} + {{range .Loose}}{{template "ms-row" (dict "Repo" $.Repo "Card" . "Class" "")}}{{end}} {{end}} {{if .Data.Unlabeled}}

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

{{end}} @@ -74,3 +83,16 @@ {{end}} {{- end}} + +{{define "ms-row" -}} + + {{.Card.ID}} + {{.Card.Title}} + + {{if .Card.PriorityLabel}}{{.Card.PriorityLabel}}{{end}} + {{if .Card.Type}}{{.Card.Type}}{{end}} + {{if .Card.Assignee}}@{{.Card.Assignee}}{{end}} + {{.Card.Category}} + + +{{- end}}