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
})
}