M web/beads.go => web/beads.go +263 -14
@@ 2,7 2,9 @@ package web
import (
"context"
+ "encoding/json"
"errors"
+ "fmt"
"net/url"
"sort"
"strconv"
@@ 56,9 58,10 @@ func (*beadsView) Applies(tables []browse.TableInfo) bool {
// --- view model --------------------------------------------------------------
// BeadsData is the opaque .Data value handed to beads.html. Mode discriminates
-// the two renderings: "board" (all lanes) or "detail" (one issue).
+// the renderings: "board" (all lanes), "detail" (one issue), or "epic" (a
+// detail whose issue is an epic, which also carries its subtask rollup).
type BeadsData struct {
- Mode string // "board" | "detail"
+ Mode string // "board" | "detail" | "epic"
// board mode
Lanes []BeadsLane
@@ 67,11 70,17 @@ type BeadsData struct {
Truncated bool // an input table exceeded beadsMax and was clipped
ShownOf int // when Truncated: the reported table total
- // detail mode
+ // detail / epic modes
Issue *BeadIssue
- DependsOn []BeadEdge // this issue depends on … (outgoing)
- DependedOnBy []BeadEdge // … is depended on by this issue (incoming)
- Comments []BeadComment
+ DependsOn []BeadEdge // this issue depends on … (outgoing)
+ DependedOnBy []BeadEdge // … is depended on by this issue (incoming)
+ Comments []BeadComment // the comment thread (Comments tab)
+ History []BeadActivity // comments + audit events, time-sorted (History tab)
+
+ // epic mode: the issue's parent-child children and their rollup.
+ Subtasks []BeadSubtask
+ SubtaskDone int // # of subtasks in the closed category
+ SubtaskTotal int // len(Subtasks); the progress denominator
}
// BeadsLane is one parade lane and the cards in it.
@@ 131,6 140,53 @@ type BeadComment struct {
CreatedAt string
}
+// BeadActivity is one entry in the merged history timeline: either a comment or
+// an audit event from the events table. Summary is a human-readable one-liner
+// ("changed status to in_progress"); Text carries the comment body or an event's
+// free-text note. Kind drives the icon/label in the template.
+type BeadActivity struct {
+ Kind string // "comment" | "event"
+ Event string // events only: the event_type (created/status_changed/updated/closed/…)
+ Actor string
+ Summary string
+ Text string
+ CreatedAt string
+}
+
+// BeadSubtask is one child of an epic — the "from" side of a parent-child
+// dependency that points at the epic. Category (open/in_progress/closed) drives
+// the status accent and feeds the epic's progress rollup.
+type BeadSubtask struct {
+ ID string
+ Title string
+ Status string
+ Category string
+ Priority string
+ Assignee string
+ Blocked bool
+}
+
+// PriorityLabel renders a subtask's numeric priority as a P-pill ("P0".."P3"),
+// or "" when unset/unparseable.
+func (s BeadSubtask) PriorityLabel() string {
+ if s.Priority == "" {
+ return ""
+ }
+ if _, err := strconv.Atoi(s.Priority); err != nil {
+ return ""
+ }
+ return "P" + s.Priority
+}
+
+// SubtaskPct is the epic's completion percentage (0..100), for the progress bar
+// width. Zero subtasks reads as 0%.
+func (d *BeadsData) SubtaskPct() int {
+ if d.SubtaskTotal == 0 {
+ return 0
+ }
+ return d.SubtaskDone * 100 / d.SubtaskTotal
+}
+
// BeadIssue is the full issue shown in the detail pane. The field set mirrors
// the user-facing columns bd surfaces for an issue (see `bd show`): identity and
// status, the four long-text bodies, effort/reference metadata, the full
@@ 306,8 362,10 @@ func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo,
}
// buildDetail assembles the single-issue view: the issue's own fields, its
-// dependency edges in both directions (target title/status resolved), and its
-// comments thread.
+// dependency edges in both directions (target title/status resolved), its
+// comments thread, and a merged history timeline. When the issue is an epic
+// (issue_type == "epic") it switches to Mode "epic" and also gathers the
+// parent-child children as a subtask rollup.
func (v *beadsView) buildDetail(
ctx context.Context, sess BrowseSession, ref, want string,
issues *browse.RowPage, issueCols map[string]int,
@@ 315,14 373,16 @@ func (v *beadsView) buildDetail(
labelsByIssue map[string][]string,
catByStatus, catByIssue map[string]string,
) *BeadsData {
- // id → (title, status) for edge labels.
+ // id → (title, status, whole row) for edge labels and the subtask rollup.
titleByIssue := map[string]string{}
statusByIssue := map[string]string{}
+ rowByID := make(map[string][]string, len(issues.Rows))
var row []string
for _, r := range issues.Rows {
id := cell(issueCols, r, "id")
titleByIssue[id] = cell(issueCols, r, "title")
statusByIssue[id] = cell(issueCols, r, "status")
+ rowByID[id] = r
if id == want {
row = r
}
@@ 335,6 395,12 @@ func (v *beadsView) buildDetail(
return data
}
+ // An epic gets its own rendering mode; the template branches on it to add the
+ // subtask rollup while reusing the shared detail chrome.
+ if strings.EqualFold(cell(issueCols, row, "issue_type"), "epic") {
+ data.Mode = "epic"
+ }
+
status := cell(issueCols, row, "status")
name, accent := laneForCategory(statusCategory(status, catByStatus))
data.Issue = &BeadIssue{
@@ 382,24 448,82 @@ func (v *beadsView) buildDetail(
}
if to == want && from != "" {
data.DependedOnBy = append(data.DependedOnBy, edge(from, typ))
+ // A parent-child edge pointing at this issue makes `from` a subtask,
+ // but that only matters when this issue is an epic.
+ if data.Mode == "epic" && strings.EqualFold(typ, "parent-child") {
+ cr := rowByID[from]
+ cat := catByIssue[from]
+ st := BeadSubtask{
+ ID: from,
+ Title: titleByIssue[from],
+ Status: statusByIssue[from],
+ Category: cat,
+ Priority: cell(issueCols, cr, "priority"),
+ Assignee: cell(issueCols, cr, "assignee"),
+ Blocked: truthy(cell(issueCols, cr, "is_blocked")),
+ }
+ data.Subtasks = append(data.Subtasks, st)
+ data.SubtaskTotal++
+ if cat == "closed" {
+ data.SubtaskDone++
+ }
+ }
}
}
+ sortSubtasks(data.Subtasks)
- // Comments are optional; a missing table just yields an empty thread.
+ // Comments are optional; a missing table just yields an empty thread. Each
+ // comment is also folded into the merged history timeline below.
if comments, _, err := readRowsOptional(ctx, sess, ref, "comments"); err == nil && comments != nil {
ccols := indexCols(comments.Columns)
for _, r := range comments.Rows {
if cell(ccols, r, "issue_id") != want {
continue
}
- data.Comments = append(data.Comments, BeadComment{
- Author: cell(ccols, r, "author"),
- Text: cell(ccols, r, "text"),
- CreatedAt: cell(ccols, r, "created_at"),
+ author := cell(ccols, r, "author")
+ text := cell(ccols, r, "text")
+ at := cell(ccols, r, "created_at")
+ data.Comments = append(data.Comments, BeadComment{Author: author, Text: text, CreatedAt: at})
+ data.History = append(data.History, BeadActivity{
+ Kind: "comment",
+ Actor: author,
+ Summary: "commented",
+ Text: text,
+ CreatedAt: at,
})
}
}
+ // The audit log (events) is optional too; when present it joins the comments
+ // in the History tab as humanized, time-ordered entries.
+ if events, _, err := readRowsOptional(ctx, sess, ref, "events"); err == nil && events != nil {
+ ecols := indexCols(events.Columns)
+ for _, r := range events.Rows {
+ if cell(ecols, r, "issue_id") != want {
+ continue
+ }
+ et := cell(ecols, r, "event_type")
+ summary, text := humanizeEvent(et, cell(ecols, r, "old_value"), cell(ecols, r, "new_value"))
+ if note := cell(ecols, r, "comment"); note != "" {
+ // An event may carry its own free-text note alongside the change.
+ if text != "" {
+ text += "\n" + note
+ } else {
+ text = note
+ }
+ }
+ data.History = append(data.History, BeadActivity{
+ Kind: "event",
+ Event: et,
+ Actor: cell(ecols, r, "actor"),
+ Summary: summary,
+ Text: text,
+ CreatedAt: cell(ecols, r, "created_at"),
+ })
+ }
+ }
+
+ sortActivity(data.History)
return data
}
@@ 535,3 659,128 @@ func priorityRank(p string) int {
}
return n
}
+
+// sortSubtasks orders an epic's children open-work-first: unclosed before
+// closed, then by priority (0 highest), then id — closed subtasks sink to the
+// bottom so the actionable ones lead.
+func sortSubtasks(subs []BeadSubtask) {
+ sort.SliceStable(subs, func(i, j int) bool {
+ ci, cj := subs[i].Category == "closed", subs[j].Category == "closed"
+ if ci != cj {
+ return !ci // open (false) sorts before closed (true)
+ }
+ pi, pj := priorityRank(subs[i].Priority), priorityRank(subs[j].Priority)
+ if pi != pj {
+ return pi < pj
+ }
+ return subs[i].ID < subs[j].ID
+ })
+}
+
+// sortActivity orders the merged history oldest-first (chronological). Timestamps
+// share the "YYYY-MM-DD HH:MM:SS" shape across events and comments, so a lexical
+// compare is a time compare; ties fall back to id-free but stable order.
+func sortActivity(acts []BeadActivity) {
+ sort.SliceStable(acts, func(i, j int) bool {
+ return acts[i].CreatedAt < acts[j].CreatedAt
+ })
+}
+
+// humanizeEvent turns one audit row into a readable summary line (and optional
+// body text). status_changed / updated carry a JSON new_value fragment
+// ({"status":"in_progress"}, {"priority":0}); created and closed are lifecycle
+// markers, with closed's new_value holding the free-text close reason.
+func humanizeEvent(eventType, oldVal, newVal string) (summary, text string) {
+ switch strings.ToLower(strings.TrimSpace(eventType)) {
+ case "created":
+ return "created the issue", ""
+ case "closed":
+ // new_value is the close reason (plain text), not JSON.
+ return "closed the issue", strings.TrimSpace(newVal)
+ case "status_changed":
+ if s := jsonField(newVal, "status"); s != "" {
+ return "changed status to " + s, ""
+ }
+ return "changed status", ""
+ case "updated":
+ if pairs := jsonPairs(newVal); pairs != "" {
+ return "updated " + pairs, ""
+ }
+ return "updated the issue", ""
+ default:
+ et := strings.ReplaceAll(strings.TrimSpace(eventType), "_", " ")
+ if et == "" {
+ et = "changed"
+ }
+ return et, ""
+ }
+}
+
+// jsonField extracts one string-ish field from a JSON object fragment, or ""
+// when the value is not a JSON object or the key is absent.
+func jsonField(raw, key string) string {
+ m := decodeJSONObject(raw)
+ if m == nil {
+ return ""
+ }
+ if v, ok := m[key]; ok {
+ return scalarString(v)
+ }
+ return ""
+}
+
+// jsonPairs renders a JSON object fragment as "k to v, k2 to v2", used for the
+// "updated …" summary. Keys are sorted for a deterministic line.
+func jsonPairs(raw string) string {
+ m := decodeJSONObject(raw)
+ if len(m) == 0 {
+ return ""
+ }
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ parts := make([]string, 0, len(keys))
+ for _, k := range keys {
+ parts = append(parts, k+" to "+scalarString(m[k]))
+ }
+ return strings.Join(parts, ", ")
+}
+
+// decodeJSONObject parses raw into a map, tolerating the browse NULL placeholder
+// and non-object payloads (returns nil rather than erroring).
+func decodeJSONObject(raw string) map[string]any {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "NULL" {
+ return nil
+ }
+ var m map[string]any
+ if err := json.Unmarshal([]byte(raw), &m); err != nil {
+ return nil
+ }
+ return m
+}
+
+// scalarString renders a decoded JSON scalar the way a person would read it:
+// integers without a trailing ".0", everything else via fmt.
+func scalarString(v any) string {
+ switch t := v.(type) {
+ case string:
+ return t
+ case float64:
+ if t == float64(int64(t)) {
+ return strconv.FormatInt(int64(t), 10)
+ }
+ return strconv.FormatFloat(t, 'g', -1, 64)
+ case bool:
+ if t {
+ return "true"
+ }
+ return "false"
+ case nil:
+ return ""
+ default:
+ return fmt.Sprintf("%v", t)
+ }
+}
M web/beads_test.go => web/beads_test.go +155 -0
@@ 352,3 352,158 @@ func TestBeadsDetailShowsCloseReason(t *testing.T) {
}
}
}
+
+// --- epic mode + history -----------------------------------------------------
+
+// beadsEpicFixture models an epic (i-epic) with three parent-child children —
+// one closed, one open, one in-progress — plus a comment and three audit events
+// on the epic, so both the subtask rollup and the merged history are exercised.
+func beadsEpicFixture() *fakeSession {
+ issues := &browse.RowPage{
+ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
+ Rows: [][]string{
+ {"i-epic", "Big Epic", "open", "1", "epic", "", "2024-01-01", "0"},
+ {"i-c1", "Child one", "open", "2", "task", "alice", "2024-01-02", "0"},
+ {"i-c2", "Child two", "closed", "1", "task", "bob", "2024-01-03", "0"},
+ {"i-c3", "Child three", "in_progress", "0", "bug", "carol", "2024-01-04", "0"},
+ },
+ Total: 4,
+ }
+ // Each child is the "from" side of a parent-child edge pointing at the epic.
+ deps := &browse.RowPage{
+ Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
+ Rows: [][]string{
+ {"d1", "i-c1", "i-epic", "parent-child"},
+ {"d2", "i-c2", "i-epic", "parent-child"},
+ {"d3", "i-c3", "i-epic", "parent-child"},
+ },
+ Total: 3,
+ }
+ statuses := &browse.RowPage{
+ Columns: []string{"name", "category"},
+ Rows: [][]string{
+ {"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"},
+ },
+ Total: 3,
+ }
+ comments := &browse.RowPage{
+ Columns: []string{"issue_id", "author", "text", "created_at"},
+ Rows: [][]string{
+ {"i-epic", "alice", "kickoff", "2024-01-05 09:00:00"},
+ {"i-c1", "bob", "unrelated", "2024-01-06 09:00:00"},
+ },
+ Total: 2,
+ }
+ events := &browse.RowPage{
+ Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
+ Rows: [][]string{
+ {"e1", "i-epic", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-01 08:00:00"},
+ {"e2", "i-epic", "status_changed", "Eugene", `{"status":"open"}`, `{"status":"in_progress"}`, "NULL", "2024-01-02 10:00:00"},
+ {"e3", "i-epic", "updated", "Eugene", "NULL", `{"priority":0}`, "NULL", "2024-01-03 11:00:00"},
+ {"e9", "i-c1", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-02 08:00:00"},
+ },
+ Total: 4,
+ }
+ return &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
+ tables: beadsTables(),
+ rowsByTable: map[string]*browse.RowPage{
+ "issues": issues,
+ "dependencies": deps,
+ "custom_statuses": statuses,
+ "comments": comments,
+ "events": events,
+ },
+ }
+}
+
+func TestBeadsEpicMode(t *testing.T) {
+ v := &beadsView{}
+ raw, err := v.Build(context.Background(), beadsEpicFixture(), nil, "main", url.Values{"issue": {"i-epic"}})
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ d := raw.(*BeadsData)
+ if d.Mode != "epic" {
+ t.Fatalf("Mode = %q, want epic", d.Mode)
+ }
+ if d.SubtaskTotal != 3 || d.SubtaskDone != 1 {
+ t.Errorf("rollup = %d/%d, want 1/3", d.SubtaskDone, d.SubtaskTotal)
+ }
+ if d.SubtaskPct() != 33 {
+ t.Errorf("pct = %d, want 33", d.SubtaskPct())
+ }
+ // Sorted open-work-first (in_progress p0, then open p2), closed sinks last.
+ gotIDs := []string{d.Subtasks[0].ID, d.Subtasks[1].ID, d.Subtasks[2].ID}
+ wantIDs := []string{"i-c3", "i-c1", "i-c2"}
+ for i := range wantIDs {
+ if gotIDs[i] != wantIDs[i] {
+ t.Errorf("subtask order = %v, want %v", gotIDs, wantIDs)
+ break
+ }
+ }
+ if d.Subtasks[2].Category != "closed" {
+ t.Errorf("last subtask category = %q, want closed", d.Subtasks[2].Category)
+ }
+}
+
+func TestBeadsHistoryMerge(t *testing.T) {
+ v := &beadsView{}
+ raw, err := v.Build(context.Background(), beadsEpicFixture(), nil, "main", url.Values{"issue": {"i-epic"}})
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ d := raw.(*BeadsData)
+
+ // Only the epic's own comment shows in the Comments tab (not i-c1's).
+ if len(d.Comments) != 1 || d.Comments[0].Text != "kickoff" {
+ t.Fatalf("comments = %+v, want just the epic's kickoff", d.Comments)
+ }
+ // History merges the epic's 1 comment + 3 events (i-c1's are excluded), time-sorted.
+ if len(d.History) != 4 {
+ t.Fatalf("history len = %d, want 4: %+v", len(d.History), d.History)
+ }
+ for i := 1; i < len(d.History); i++ {
+ if d.History[i-1].CreatedAt > d.History[i].CreatedAt {
+ t.Errorf("history not time-sorted at %d: %q > %q", i, d.History[i-1].CreatedAt, d.History[i].CreatedAt)
+ }
+ }
+ if d.History[0].Kind != "event" || d.History[0].Summary != "created the issue" {
+ t.Errorf("first history = %+v, want created event", d.History[0])
+ }
+ last := d.History[len(d.History)-1]
+ if last.Kind != "comment" || last.Text != "kickoff" {
+ t.Errorf("last history = %+v, want the kickoff comment", last)
+ }
+ // Humanized change lines.
+ var sawStatus, sawUpdate bool
+ for _, a := range d.History {
+ switch a.Summary {
+ case "changed status to in_progress":
+ sawStatus = true
+ case "updated priority to 0":
+ sawUpdate = true
+ }
+ }
+ if !sawStatus || !sawUpdate {
+ t.Errorf("history missing humanized change lines; status=%v update=%v", sawStatus, sawUpdate)
+ }
+}
+
+func TestBeadsEpicViewRender(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 = beadsEpicFixture()
+ setViews(t, h, &beadsView{})
+
+ rec := h.do("GET", "/~alice/db/view/beads?issue=i-epic", nil, nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("epic view: got %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ for _, want := range []string{"Subtasks", "1 of 3 done", "epic-progress", "Child three", "History", "changed status to in_progress"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("epic render missing %q", want)
+ }
+ }
+}
A web/realdata_test.go => web/realdata_test.go +83 -0
@@ 0,0 1,83 @@
+package web
+
+import (
+ "context"
+ "net/url"
+ "os"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+)
+
+// TestRealBeadsStore is a manual smoke test against a real beads database. It
+// is skipped unless BEADS_REAL_DB points at a *bare* NBS store dir (the shape
+// production serves — an embedded working store has a chunk journal and won't
+// open; push it to a file remote first: `dolt push file:///path/bare main`).
+// Optionally set BEADS_REAL_ISSUE=<id> to dump one issue's epic rollup and
+// merged history. Kept because browse reaches into version-fragile dolt
+// internals, so a module bump should be re-verified against real data here.
+//
+// dolt push file:///tmp/bare main # from the working store
+// BEADS_REAL_DB=/tmp/bare BEADS_REAL_ISSUE=<epic-id> \
+// go test -tags gms_pure_go -run TestRealBeadsStore -v ./web/
+func TestRealBeadsStore(t *testing.T) {
+ path := os.Getenv("BEADS_REAL_DB")
+ if path == "" {
+ t.Skip("set BEADS_REAL_DB to a real beads noms store dir")
+ }
+ ctx := context.Background()
+ dbh, err := browse.Open(ctx, path)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer dbh.Close()
+
+ branches, err := dbh.Branches(ctx)
+ if err != nil {
+ t.Fatalf("branches: %v", err)
+ }
+ ref := browse.DefaultBranch(branches)
+ t.Logf("branches=%v default=%q", branches, ref)
+
+ tables, err := dbh.Tables(ctx, ref)
+ if err != nil {
+ t.Fatalf("tables: %v", err)
+ }
+ var names []string
+ for _, tb := range tables {
+ names = append(names, tb.Name)
+ }
+ t.Logf("tables=%v", names)
+
+ issues, err := dbh.Rows(ctx, ref, "issues", 0, 5)
+ if err != nil {
+ t.Fatalf("rows: %v", err)
+ }
+ t.Logf("issues cols=%v total=%d", issues.Columns, issues.Total)
+
+ // Drive the real view. With BEADS_REAL_ISSUE set to an epic id, dump the
+ // subtask rollup and merged history so a human can eyeball the output.
+ want := os.Getenv("BEADS_REAL_ISSUE")
+ if want == "" {
+ return
+ }
+ v := &beadsView{}
+ raw, err := v.Build(ctx, dbh, nil, ref, url.Values{"issue": {want}})
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ data := raw.(*BeadsData)
+ if data.Issue == nil {
+ t.Fatalf("issue %q not found", want)
+ }
+ t.Logf("mode=%q issue=%q type=%q", data.Mode, data.Issue.Title, data.Issue.IssueType)
+ t.Logf("desc=%.80q closeReason=%.80q", data.Issue.Description, data.Issue.CloseReason)
+ t.Logf("subtasks %d/%d done (%d%%):", data.SubtaskDone, data.SubtaskTotal, data.SubtaskPct())
+ for _, s := range data.Subtasks {
+ t.Logf(" - %s [%s/%s] %s @%s", s.ID, s.Category, s.Status, s.Title, s.Assignee)
+ }
+ t.Logf("history (%d entries):", len(data.History))
+ for _, a := range data.History {
+ t.Logf(" %s | %s: %s %s | %s", a.CreatedAt, a.Kind, a.Actor, a.Summary, a.Text)
+ }
+}
M web/templates/beads.html => web/templates/beads.html +103 -10
@@ 93,19 93,59 @@
.bead-comment { border: 1px solid var(--bd-border); border-left: 2px solid var(--lane-linedup); padding: .4rem .6rem; margin-bottom: .5rem; }
.bead-comment .c-head { font-size: .8rem; color: var(--bd-muted); margin-bottom: .2rem; }
.bead-comment .c-body { white-space: pre-wrap; color: var(--bd-fg); }
+
+/* epic: badge, subtask rollup, progress meter */
+.epic-badge { border-color: var(--lane-stalled); color: var(--bd-fg); text-transform: uppercase; letter-spacing: .04em; }
+.epic-subtasks { margin-bottom: 1.25rem; }
+.epic-progress { height: 6px; background: var(--bd-panel); border: 1px solid var(--bd-border); margin: .1rem 0 .6rem; }
+.epic-progress-bar { height: 100%; background: var(--lane-linedup); }
+.subtask-list li { padding: .3rem 0; }
+/* a thin status accent on the left cap of each subtask row */
+.subtask-list li.subtask { border-left: 2px solid var(--bd-border); padding-left: .5rem; }
+.subtask-list li.subtask.in_progress { border-left-color: var(--lane-rolling); }
+.subtask-list li.subtask.closed { border-left-color: var(--lane-past); }
+.subtask-list li.subtask.closed a code { text-decoration: line-through; }
+
+/* activity: Comments | History tabs (pure-CSS radio switch, no JS) */
+.bead-activity .acttab-radio { position: absolute; opacity: 0; pointer-events: none; }
+.acttabs { display: flex; gap: 0; border-bottom: 1px solid var(--bd-border); margin-bottom: .75rem; }
+.acttabs label {
+ padding: .35rem .85rem; cursor: pointer; color: var(--bd-muted);
+ border: 1px solid transparent; border-bottom: none; margin-bottom: -1px;
+}
+#acttab-comments:checked ~ .acttabs label[for="acttab-comments"],
+#acttab-history:checked ~ .acttabs label[for="acttab-history"] {
+ color: var(--bd-fg); background: var(--bd-panel);
+ border-color: var(--bd-border); border-bottom: 1px solid var(--bd-panel);
+}
+.bead-activity .actpanel { display: none; }
+#acttab-comments:checked ~ .panel-comments { display: block; }
+#acttab-history:checked ~ .panel-history { display: block; }
+
+.bead-timeline { list-style: none; padding-left: 0; margin: 0; border-left: 2px solid var(--bd-border); }
+.bead-timeline .tl-item { position: relative; padding: .2rem 0 .5rem .8rem; }
+.bead-timeline .tl-item::before {
+ content: ""; position: absolute; left: -5px; top: .5rem;
+ width: 8px; height: 8px; background: var(--bd-muted); border: 1px solid var(--bd-bg);
+}
+.bead-timeline .tl-comment::before { background: var(--lane-linedup); }
+.bead-timeline .tl-head { font-size: .85rem; color: var(--bd-fg); }
+.bead-timeline .tl-when { color: var(--bd-muted); }
+.bead-timeline .tl-body { white-space: pre-wrap; color: var(--bd-fg); margin-top: .15rem; padding-left: .1rem; }
</style>
<div class="beads">
<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> · parade</h2>
{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "beads" "Ref" .Ref)}}
-{{if eq .Data.Mode "detail"}}
-{{/* ---------------- detail pane ---------------- */}}
+{{if or (eq .Data.Mode "detail") (eq .Data.Mode "epic")}}
+{{/* ------------- detail / epic pane ------------- */}}
<p><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads">← Back to the parade</a></p>
{{with .Data.Issue}}
<div class="bead-detail">
<h2 class="ticket-title">
<code>{{.ID}}</code> <span>{{.Title}}</span>
+ {{if eq $.Data.Mode "epic"}}<span class="blabel epic-badge">epic</span>{{end}}
<span class="lane-label" style="--lane: {{.Accent}};"><span class="swatch"></span>{{.Lane}}</span>
</h2>
<div class="r-meta mb-3">
@@ 126,7 166,6 @@
{{if .StartedAt}}<tr><td class="field-label">Started</td><td>{{.StartedAt}}</td></tr>{{end}}
{{if .UpdatedAt}}<tr><td class="field-label">Updated</td><td>{{.UpdatedAt}}</td></tr>{{end}}
{{if .ClosedAt}}<tr><td class="field-label">Closed</td><td>{{.ClosedAt}}</td></tr>{{end}}
- {{if .CloseReason}}<tr><td class="field-label">Close reason</td><td>{{.CloseReason}}</td></tr>{{end}}
</tbody>
</table>
{{if .Description}}<div class="field-label">Description</div><pre class="field-body">{{.Description}}</pre>{{end}}
@@ 135,6 174,29 @@
{{if .Notes}}<div class="field-label">Notes</div><pre class="field-body">{{.Notes}}</pre>{{end}}
</div>
+{{if eq $.Data.Mode "epic"}}
+<div class="epic-subtasks">
+ <h4>Subtasks{{if $.Data.SubtaskTotal}} <small class="text-muted">{{$.Data.SubtaskDone}} of {{$.Data.SubtaskTotal}} done</small>{{end}}</h4>
+ {{if $.Data.SubtaskTotal}}
+ <div class="epic-progress" title="{{$.Data.SubtaskPct}}% complete">
+ <div class="epic-progress-bar" style="width: {{$.Data.SubtaskPct}}%"></div>
+ </div>
+ <ul class="bead-deplist subtask-list">
+ {{range $.Data.Subtasks}}
+ <li class="subtask {{.Category}}">
+ <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.ID | urlquery}}"><code>{{.ID}}</code></a>
+ {{if .Title}}— {{.Title}}{{end}}
+ {{if .PriorityLabel}}<span class="blabel prio">{{.PriorityLabel}}</span>{{end}}
+ <span class="blabel muted">{{.Status}}</span>
+ {{if .Assignee}}<span class="blabel assignee">@{{.Assignee}}</span>{{end}}
+ {{if .Blocked}}<span class="blabel block">🚧</span>{{end}}
+ </li>
+ {{end}}
+ </ul>
+ {{else}}<p class="beads-empty">No subtasks linked to this epic.</p>{{end}}
+</div>
+{{end}}
+
<div class="row">
<div class="col-md-6">
<h4>Depends on</h4>
@@ 167,15 229,46 @@
</div>
</div>
-<h4>Comments</h4>
-{{if $.Data.Comments}}
-{{range $.Data.Comments}}
-<div class="bead-comment">
- <div class="c-head"><strong>{{.Author}}</strong> {{if .CreatedAt}}· {{.CreatedAt}}{{end}}</div>
- <div class="c-body">{{.Text}}</div>
+<div class="bead-activity">
+ <input type="radio" name="acttab" id="acttab-comments" class="acttab-radio" checked>
+ <input type="radio" name="acttab" id="acttab-history" class="acttab-radio">
+ <div class="acttabs">
+ <label for="acttab-comments">Comments</label>
+ <label for="acttab-history">History</label>
+ </div>
+
+ <div class="actpanel panel-comments">
+ {{if $.Data.Comments}}
+ {{range $.Data.Comments}}
+ <div class="bead-comment">
+ <div class="c-head"><strong>{{.Author}}</strong> {{if .CreatedAt}}· {{.CreatedAt}}{{end}}</div>
+ <div class="c-body">{{.Text}}</div>
+ </div>
+ {{end}}
+ {{else}}<p class="beads-empty">No comments.</p>{{end}}
+ </div>
+
+ <div class="actpanel panel-history">
+ {{if $.Data.History}}
+ <ul class="bead-timeline">
+ {{range $.Data.History}}
+ <li class="tl-item tl-{{.Kind}}">
+ <div class="tl-head">
+ <strong>{{.Actor}}</strong> {{.Summary}}
+ {{if .CreatedAt}}<span class="tl-when">· {{.CreatedAt}}</span>{{end}}
+ </div>
+ {{if .Text}}<div class="tl-body">{{.Text}}</div>{{end}}
+ </li>
+ {{end}}
+ </ul>
+ {{else}}<p class="beads-empty">No activity yet.</p>{{end}}
+ </div>
</div>
+
+{{if .CloseReason}}
+<h4>Close reason</h4>
+<pre class="field-body">{{.CloseReason}}</pre>
{{end}}
-{{else}}<p class="beads-empty">No comments.</p>{{end}}
{{else}}
<div class="alert alert-warning">Issue not found.</div>
{{end}}