~bigbes/sr-ht-dolt

8f5ae3f0a62b982df3ab607539761b2fbc8b0f8e — Eugene Blikh 30 days ago 4520d5c
feat(web): Mardi Gras beads view

Render a beads (bd) issue database as a Mardi Gras parade board: four
lanes — Rolling (in progress), Lined Up (open & ready), Stalled (open &
blocked), Past Stand (done) — plus a marquee of per-lane counts. Cards
show id/title/type/priority/assignee/labels and blocked-by/blocks counts;
an issue-detail pane (?issue=<id>) shows the full issue with its
dependency edges in both directions and its comments thread.

Fingerprints on the issues + dependencies tables and reads everything
through the browse layer (column-name-keyed, so column order is
irrelevant), capped at 2000 rows/table. Status is bucketed via
custom_statuses with name heuristics as a fallback; "blocked" means
is_blocked or an open blocking dependency. Plugs into the view registry
via init()+RegisterView with no framework edits. Purple/gold/green
palette scoped in the template (no scss rebuild needed).
3 files changed, 1056 insertions(+), 0 deletions(-)

A web/beads.go
A web/beads_test.go
A web/templates/beads.html
A web/beads.go => web/beads.go +520 -0
@@ 0,0 1,520 @@
package web

import (
	"context"
	"errors"
	"net/url"
	"sort"
	"strconv"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// beadsView renders a "beads" (bd) issue database as a Mardi Gras parade board:
// four lanes of cards (Rolling / Lined Up / Stalled / Past Stand) plus a
// per-issue detail pane reachable via ?issue=<id>. All data is read through the
// BrowseSession surface (Rows/Tables) — there is no SQL engine behind it.
type beadsView struct{}

func init() { RegisterView(&beadsView{}) }

func (*beadsView) Name() string     { return "beads" }
func (*beadsView) Label() string    { return "Beads" }
func (*beadsView) Template() string { return "beads.html" }

// beadsMax caps how many rows of any single table the view reads. Beads DBs are
// modest (hundreds–low thousands of issues); if a table exceeds this the board
// notes it is truncated rather than trying to page.
const beadsMax = 2000

// Applies fingerprints a beads DB: both an "issues" and a "dependencies" table
// present, and "issues" carrying at least id + status columns (a cheap guard
// against an unrelated schema that happens to reuse those two table names).
func (*beadsView) Applies(tables []browse.TableInfo) bool {
	var haveIssues, haveDeps, haveID, haveStatus bool
	for _, t := range tables {
		switch t.Name {
		case "issues":
			haveIssues = true
			for _, c := range t.Columns {
				switch c.Name {
				case "id":
					haveID = true
				case "status":
					haveStatus = true
				}
			}
		case "dependencies":
			haveDeps = true
		}
	}
	return haveIssues && haveDeps && haveID && haveStatus
}

// --- view model --------------------------------------------------------------

// BeadsData is the opaque .Data value handed to beads.html. Mode discriminates
// the two renderings: "board" (all lanes) or "detail" (one issue).
type BeadsData struct {
	Mode string // "board" | "detail"

	// board mode
	Lanes     []BeadsLane
	Counts    BeadsCounts
	Total     int  // total issues placed on the board
	Truncated bool // an input table exceeded beadsMax and was clipped
	ShownOf   int  // when Truncated: the reported table total

	// detail mode
	Issue        *BeadIssue
	DependsOn    []BeadEdge // this issue depends on … (outgoing)
	DependedOnBy []BeadEdge // … is depended on by this issue (incoming)
	Comments     []BeadComment
}

// BeadsLane is one parade lane and the cards in it.
type BeadsLane struct {
	Name   string // human label, e.g. "Rolling"
	Slug   string // css-safe identifier, e.g. "rolling"
	Accent string // hex accent color for the lane header/border
	Issues []BeadCard
}

// BeadsCounts is the marquee: per-lane totals plus the grand total.
type BeadsCounts struct {
	Rolling   int
	LinedUp   int
	Stalled   int
	PastStand int
	Total     int
}

// BeadCard is one issue as it appears on the board.
type BeadCard struct {
	ID        string
	Title     string
	Type      string
	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)
}

// PriorityLabel renders the numeric priority as a P-pill label ("P0".."P3"),
// or "" when unset/unparseable so the template can omit the marker.
func (c BeadCard) PriorityLabel() string {
	if c.Priority == "" {
		return ""
	}
	if _, err := strconv.Atoi(c.Priority); err != nil {
		return ""
	}
	return "P" + c.Priority
}

// BeadEdge is one dependency edge to another issue, linked in the detail pane.
type BeadEdge struct {
	IssueID string
	Title   string
	Type    string
	Status  string
	Closed  bool
}

// BeadComment is one row of the comments thread.
type BeadComment struct {
	Author    string
	Text      string
	CreatedAt string
}

// BeadIssue is the full issue shown in the detail pane.
type BeadIssue struct {
	ID                 string
	Title              string
	Status             string
	Lane               string
	Accent             string
	Priority           string
	IssueType          string
	Assignee           string
	CreatedBy          string
	Owner              string
	Description        string
	Design             string
	AcceptanceCriteria string
	Notes              string
	CreatedAt          string
	UpdatedAt          string
	ClosedAt           string
	Labels             []string
}

// --- build -------------------------------------------------------------------

// Build reads the issue graph and produces either the board or, when ?issue=
// names an issue, that issue's detail pane.
func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, query url.Values) (any, error) {
	issues, issuesTotal, err := readRows(ctx, sess, ref, "issues")
	if err != nil {
		return nil, err
	}
	deps, depsTotal, err := readRows(ctx, sess, ref, "dependencies")
	if err != nil {
		return nil, err
	}
	// Optional tables: absent ones degrade to empty rather than failing the view.
	labels, _, _ := readRowsOptional(ctx, sess, ref, "labels")
	statuses, _, _ := readRowsOptional(ctx, sess, ref, "custom_statuses")

	truncated := issuesTotal > beadsMax || depsTotal > beadsMax
	shownOf := issuesTotal

	// status name → category, from custom_statuses (may be empty → heuristics).
	catByStatus := map[string]string{}
	if statuses != nil {
		nameIdx := statuses.Columns
		cols := indexCols(nameIdx)
		for _, r := range statuses.Rows {
			name := cell(cols, r, "name")
			cat := cell(cols, r, "category")
			if name != "" {
				catByStatus[strings.ToLower(name)] = strings.ToLower(cat)
			}
		}
	}

	// issue id → category, needed to decide whether a blocking target is "open".
	issueCols := indexCols(issues.Columns)
	catByIssue := make(map[string]string, len(issues.Rows))
	for _, r := range issues.Rows {
		id := cell(issueCols, r, "id")
		catByIssue[id] = statusCategory(cell(issueCols, r, "status"), catByStatus)
	}

	// Aggregate dependency edges by issue.
	depCols := indexCols(deps.Columns)
	blockedByCount := map[string]int{} // issue_id → #deps it has
	blocksCount := map[string]int{}    // depends_on_issue_id → #deps aimed at it
	blockedOpen := map[string]bool{}   // issue_id → has an open blocking dep
	for _, r := range deps.Rows {
		from := cell(depCols, r, "issue_id")
		to := cell(depCols, r, "depends_on_issue_id")
		typ := strings.ToLower(cell(depCols, r, "type"))
		if from != "" {
			blockedByCount[from]++
		}
		if to != "" {
			blocksCount[to]++
		}
		if from != "" && (typ == "blocks" || typ == "parent-child") {
			// Blocked only while the thing it waits on is not yet closed.
			if catByIssue[to] != "closed" {
				blockedOpen[from] = true
			}
		}
	}

	// labels: issue_id → [label]
	labelsByIssue := map[string][]string{}
	if labels != nil {
		lcols := indexCols(labels.Columns)
		for _, r := range labels.Rows {
			id := cell(lcols, r, "issue_id")
			lb := cell(lcols, r, "label")
			if id != "" && lb != "" {
				labelsByIssue[id] = append(labelsByIssue[id], lb)
			}
		}
	}

	// Detail mode: a named issue short-circuits the board build.
	if want := query.Get("issue"); want != "" {
		return v.buildDetail(ctx, sess, ref, want, issues, issueCols, deps, depCols,
			labelsByIssue, catByStatus, catByIssue), nil
	}

	// Board mode: bucket every issue into exactly one lane.
	var rolling, linedUp, stalled, pastStand []BeadCard
	for _, r := range issues.Rows {
		id := cell(issueCols, r, "id")
		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"),
			Labels:    labelsByIssue[id],
			BlockedBy: blockedByCount[id],
			Blocks:    blocksCount[id],
		}
		cat := catByIssue[id]
		blocked := truthy(cell(issueCols, r, "is_blocked")) || blockedOpen[id]

		switch {
		case cat == "closed":
			pastStand = append(pastStand, card)
		case cat == "in_progress":
			rolling = append(rolling, card)
		case blocked:
			stalled = append(stalled, card)
		default: // open (or unknown) and not blocked
			linedUp = append(linedUp, card)
		}
	}

	created := issueCreatedAt(issues, issueCols)
	for _, lane := range [][]BeadCard{rolling, linedUp, stalled, pastStand} {
		sortCards(lane, created)
	}

	data := &BeadsData{
		Mode: "board",
		Lanes: []BeadsLane{
			{Name: "Rolling", Slug: "rolling", Accent: "#f5c518", Issues: rolling},
			{Name: "Lined Up", Slug: "lined-up", Accent: "#2e9e4f", Issues: linedUp},
			{Name: "Stalled", Slug: "stalled", Accent: "#7b2ff7", Issues: stalled},
			{Name: "Past Stand", Slug: "past-stand", Accent: "#8a8d91", Issues: pastStand},
		},
		Counts: BeadsCounts{
			Rolling:   len(rolling),
			LinedUp:   len(linedUp),
			Stalled:   len(stalled),
			PastStand: len(pastStand),
			Total:     len(issues.Rows),
		},
		Total:     len(issues.Rows),
		Truncated: truncated,
		ShownOf:   shownOf,
	}
	return data, nil
}

// 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.
func (v *beadsView) buildDetail(
	ctx context.Context, sess BrowseSession, ref, want string,
	issues *browse.RowPage, issueCols map[string]int,
	deps *browse.RowPage, depCols map[string]int,
	labelsByIssue map[string][]string,
	catByStatus, catByIssue map[string]string,
) *BeadsData {
	// id → (title, status) for edge labels.
	titleByIssue := map[string]string{}
	statusByIssue := map[string]string{}
	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")
		if id == want {
			row = r
		}
	}

	data := &BeadsData{Mode: "detail"}
	if row == nil {
		// Unknown id: a detail pane with a nil Issue; the template shows a
		// "not found" note and a link back to the board.
		return data
	}

	status := cell(issueCols, row, "status")
	name, accent := laneForCategory(statusCategory(status, catByStatus))
	data.Issue = &BeadIssue{
		ID:                 want,
		Title:              cell(issueCols, row, "title"),
		Status:             status,
		Lane:               name,
		Accent:             accent,
		Priority:           cell(issueCols, row, "priority"),
		IssueType:          cell(issueCols, row, "issue_type"),
		Assignee:           cell(issueCols, row, "assignee"),
		CreatedBy:          cell(issueCols, row, "created_by"),
		Owner:              cell(issueCols, row, "owner"),
		Description:        cell(issueCols, row, "description"),
		Design:             cell(issueCols, row, "design"),
		AcceptanceCriteria: cell(issueCols, row, "acceptance_criteria"),
		Notes:              cell(issueCols, row, "notes"),
		CreatedAt:          cell(issueCols, row, "created_at"),
		UpdatedAt:          cell(issueCols, row, "updated_at"),
		ClosedAt:           cell(issueCols, row, "closed_at"),
		Labels:             labelsByIssue[want],
	}

	edge := func(id, typ string) BeadEdge {
		st := statusByIssue[id]
		return BeadEdge{
			IssueID: id,
			Title:   titleByIssue[id],
			Type:    typ,
			Status:  st,
			Closed:  statusCategory(st, catByStatus) == "closed",
		}
	}
	for _, r := range deps.Rows {
		from := cell(depCols, r, "issue_id")
		to := cell(depCols, r, "depends_on_issue_id")
		typ := cell(depCols, r, "type")
		if from == want && to != "" {
			data.DependsOn = append(data.DependsOn, edge(to, typ))
		}
		if to == want && from != "" {
			data.DependedOnBy = append(data.DependedOnBy, edge(from, typ))
		}
	}

	// Comments are optional; a missing table just yields an empty thread.
	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"),
			})
		}
	}

	return data
}

// --- helpers -----------------------------------------------------------------

// readRows reads up to beadsMax rows of a required table and its reported total.
func readRows(ctx context.Context, sess BrowseSession, ref, table string) (*browse.RowPage, int, error) {
	page, err := sess.Rows(ctx, ref, table, 0, beadsMax)
	if err != nil {
		return nil, 0, err
	}
	return page, page.Total, nil
}

// readRowsOptional is readRows for a table that may not exist: ErrTableNotFound
// degrades to (nil, 0, nil) so the caller can treat it as empty.
func readRowsOptional(ctx context.Context, sess BrowseSession, ref, table string) (*browse.RowPage, int, error) {
	page, err := sess.Rows(ctx, ref, table, 0, beadsMax)
	if err != nil {
		if errors.Is(err, browse.ErrTableNotFound) {
			return nil, 0, nil
		}
		return nil, 0, err
	}
	return page, page.Total, nil
}

// indexCols builds a column-name → cell-index map from a RowPage's Columns, so
// cells are addressed by name regardless of the underlying column order.
func indexCols(cols []string) map[string]int {
	m := make(map[string]int, len(cols))
	for i, c := range cols {
		m[c] = i
	}
	return m
}

// cell returns the named column's value for a row, or "" when the column is
// absent, out of range, or the literal browse NULL placeholder.
func cell(cols map[string]int, row []string, name string) string {
	i, ok := cols[name]
	if !ok || i < 0 || i >= len(row) {
		return ""
	}
	v := row[i]
	if v == "NULL" {
		return ""
	}
	return v
}

// truthy reports whether a cell reads as a set boolean/flag.
func truthy(s string) bool {
	switch strings.ToLower(strings.TrimSpace(s)) {
	case "1", "true", "yes", "t", "y":
		return true
	}
	return false
}

// statusCategory maps a status name to one of open / in_progress / closed. It
// prefers the custom_statuses lookup and falls back to name heuristics when the
// status is unknown there (or the table was empty).
func statusCategory(status string, catByStatus map[string]string) string {
	s := strings.ToLower(strings.TrimSpace(status))
	if s == "" {
		return "open"
	}
	if cat, ok := catByStatus[s]; ok && cat != "" {
		switch cat {
		case "in_progress", "closed", "open":
			return cat
		}
	}
	switch {
	case strings.Contains(s, "progress"), strings.Contains(s, "doing"), strings.Contains(s, "active"), s == "wip":
		return "in_progress"
	case strings.Contains(s, "close"), strings.Contains(s, "done"), strings.Contains(s, "resolved"), strings.Contains(s, "complete"):
		return "closed"
	default:
		return "open"
	}
}

// laneForCategory returns the lane display name and accent for a status
// category (used by the detail pane; the board buckets inline because it also
// needs the blocked signal).
func laneForCategory(cat string) (name, accent string) {
	switch cat {
	case "closed":
		return "Past Stand", "#8a8d91"
	case "in_progress":
		return "Rolling", "#f5c518"
	default:
		return "Lined Up", "#2e9e4f"
	}
}

// issueCreatedAt maps issue id → created_at string, for lane sorting.
func issueCreatedAt(issues *browse.RowPage, cols map[string]int) map[string]string {
	m := make(map[string]string, len(issues.Rows))
	for _, r := range issues.Rows {
		m[cell(cols, r, "id")] = cell(cols, r, "created_at")
	}
	return m
}

// sortCards orders a lane by priority (0 = highest first), then created_at
// ascending, then id — a stable, deterministic parade order.
func sortCards(cards []BeadCard, created map[string]string) {
	sort.SliceStable(cards, func(i, j int) bool {
		pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority)
		if pi != pj {
			return pi < pj
		}
		ci, cj := created[cards[i].ID], created[cards[j].ID]
		if ci != cj {
			return ci < cj
		}
		return cards[i].ID < cards[j].ID
	})
}

// priorityRank parses a priority to an int for sorting; unset/unparseable sorts
// last (a large rank).
func priorityRank(p string) int {
	if p == "" {
		return 1 << 30
	}
	n, err := strconv.Atoi(strings.TrimSpace(p))
	if err != nil {
		return 1 << 30
	}
	return n
}

A web/beads_test.go => web/beads_test.go +333 -0
@@ 0,0 1,333 @@
package web

import (
	"context"
	"net/http"
	"net/url"
	"strings"
	"testing"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// --- fixtures ----------------------------------------------------------------

// beadsTables is a schema fingerprint that Applies should accept: issues (with
// id + status) + dependencies both present.
func beadsTables() []browse.TableInfo {
	return []browse.TableInfo{
		{Name: "issues", Columns: []browse.ColumnInfo{
			{Name: "id", PrimaryKey: true}, {Name: "status"},
		}},
		{Name: "dependencies", Columns: []browse.ColumnInfo{{Name: "id", PrimaryKey: true}}},
		{Name: "labels"},
	}
}

// beadsFixture wires a fakeSession whose per-table Rows model a small parade:
//   - i-open   : open, ready         → Lined Up
//   - i-prog   : in_progress         → Rolling
//   - i-done   : closed              → Past Stand
//   - i-blocked: open, blocked by i-open (a "blocks" dep to a non-closed target)
//     and also carries is_blocked=1  → Stalled
//
// The issues page deliberately orders its columns id,title,status,priority,...
// with is_blocked LAST so column-name mapping (not positional) is exercised.
func beadsFixture() *fakeSession {
	issues := &browse.RowPage{
		// Column order chosen so nothing is at a "natural" index; is_blocked is last.
		Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
		Rows: [][]string{
			{"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "0"},
			{"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "0"},
			{"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "0"},
			{"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "1"},
		},
		Total: 4,
	}
	// i-blocked depends on i-open (blocks, target open → keeps it Stalled).
	// i-open is depended on by i-blocked → i-open.Blocks == 1.
	deps := &browse.RowPage{
		Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
		Rows: [][]string{
			{"d1", "i-blocked", "i-open", "blocks"},
		},
		Total: 1,
	}
	labels := &browse.RowPage{
		Columns: []string{"issue_id", "label"},
		Rows: [][]string{
			{"i-open", "backend"},
			{"i-open", "urgent"},
		},
		Total: 2,
	}
	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-open", "alice", "first!", "2024-01-05"},
			{"i-prog", "bob", "not this one", "2024-01-06"},
		},
		Total: 2,
	}
	return &fakeSession{
		branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
		tables:   beadsTables(),
		rowsByTable: map[string]*browse.RowPage{
			"issues":          issues,
			"dependencies":    deps,
			"labels":          labels,
			"custom_statuses": statuses,
			"comments":        comments,
		},
	}
}

// laneBySlug finds a lane in a built board by its slug.
func laneBySlug(d *BeadsData, slug string) *BeadsLane {
	for i := range d.Lanes {
		if d.Lanes[i].Slug == slug {
			return &d.Lanes[i]
		}
	}
	return nil
}

// cardIDs lists the ids of a lane's cards.
func cardIDs(l *BeadsLane) []string {
	if l == nil {
		return nil
	}
	out := make([]string, len(l.Issues))
	for i, c := range l.Issues {
		out[i] = c.ID
	}
	return out
}

// --- Applies -----------------------------------------------------------------

func TestBeadsApplies(t *testing.T) {
	v := &beadsView{}
	if !v.Applies(beadsTables()) {
		t.Fatalf("Applies should be true when issues+dependencies (with id+status) present")
	}
	// Missing dependencies → not a beads DB.
	if v.Applies([]browse.TableInfo{
		{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}, {Name: "status"}}},
	}) {
		t.Fatalf("Applies should be false without a dependencies table")
	}
	// issues present but lacking status column → guard rejects.
	if v.Applies([]browse.TableInfo{
		{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}}},
		{Name: "dependencies"},
	}) {
		t.Fatalf("Applies should be false when issues lacks a status column")
	}
	// Unrelated schema.
	if v.Applies([]browse.TableInfo{{Name: "widgets"}}) {
		t.Fatalf("Applies should be false for an unrelated schema")
	}
}

// --- board mode --------------------------------------------------------------

func TestBeadsBuildBoardLanes(t *testing.T) {
	v := &beadsView{}
	got, err := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "alice", Name: "db"}, "main", url.Values{})
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	d, ok := got.(*BeadsData)
	if !ok {
		t.Fatalf("Build returned %T, want *BeadsData", got)
	}
	if d.Mode != "board" {
		t.Fatalf("Mode = %q, want board", d.Mode)
	}

	checks := map[string][]string{
		"rolling":    {"i-prog"},
		"lined-up":   {"i-open"},
		"stalled":    {"i-blocked"},
		"past-stand": {"i-done"},
	}
	for slug, want := range checks {
		got := cardIDs(laneBySlug(d, slug))
		if strings.Join(got, ",") != strings.Join(want, ",") {
			t.Errorf("lane %s = %v, want %v", slug, got, want)
		}
	}

	if d.Counts.Rolling != 1 || d.Counts.LinedUp != 1 || d.Counts.Stalled != 1 || d.Counts.PastStand != 1 {
		t.Errorf("counts = %+v, want 1 each", d.Counts)
	}
	if d.Counts.Total != 4 || d.Total != 4 {
		t.Errorf("total = %d/%d, want 4", d.Counts.Total, d.Total)
	}
}

func TestBeadsBuildBoardCounts(t *testing.T) {
	v := &beadsView{}
	got, _ := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", url.Values{})
	d := got.(*BeadsData)

	// i-blocked depends on i-open → i-blocked.BlockedBy==1, i-open.Blocks==1.
	blocked := laneBySlug(d, "stalled").Issues[0]
	if blocked.ID != "i-blocked" || blocked.BlockedBy != 1 || blocked.Blocks != 0 {
		t.Errorf("i-blocked = %+v, want BlockedBy=1 Blocks=0", blocked)
	}
	open := laneBySlug(d, "lined-up").Issues[0]
	if open.ID != "i-open" || open.Blocks != 1 || open.BlockedBy != 0 {
		t.Errorf("i-open = %+v, want Blocks=1 BlockedBy=0", open)
	}
	// Labels attach by issue_id.
	if strings.Join(open.Labels, ",") != "backend,urgent" {
		t.Errorf("i-open labels = %v, want [backend urgent]", open.Labels)
	}
}

// TestBeadsBlockedByDepOnly proves the dependency-derived block signal works
// even when is_blocked is not set: an issue with a "blocks" dep to a non-closed
// target lands in Stalled; the same dep to a CLOSED target does not.
func TestBeadsBlockedByDepOnly(t *testing.T) {
	sess := &fakeSession{
		tables: beadsTables(),
		rowsByTable: map[string]*browse.RowPage{
			"issues": {
				Columns: []string{"id", "status", "is_blocked"},
				Rows: [][]string{
					{"a", "open", "0"},   // blocked by open b → Stalled
					{"b", "open", "0"},   // ready → Lined Up
					{"c", "open", "0"},   // "blocked" by closed d → NOT stalled → Lined Up
					{"d", "closed", "0"}, // Past Stand
				},
				Total: 4,
			},
			"dependencies": {
				Columns: []string{"issue_id", "depends_on_issue_id", "type"},
				Rows: [][]string{
					{"a", "b", "blocks"},
					{"c", "d", "blocks"},
				},
				Total: 2,
			},
		},
	}
	v := &beadsView{}
	got, err := v.Build(context.Background(), sess, &core.Repo{OwnerName: "a", Name: "b"}, "main", url.Values{})
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	d := got.(*BeadsData)
	if ids := cardIDs(laneBySlug(d, "stalled")); strings.Join(ids, ",") != "a" {
		t.Errorf("stalled = %v, want [a] (blocked by open dep only)", ids)
	}
	if ids := cardIDs(laneBySlug(d, "lined-up")); strings.Join(ids, ",") != "b,c" {
		t.Errorf("lined-up = %v, want [b c] (c's blocker is closed)", ids)
	}
}

// --- detail mode -------------------------------------------------------------

func TestBeadsBuildDetail(t *testing.T) {
	v := &beadsView{}
	q := url.Values{}
	q.Set("issue", "i-open")
	got, err := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", q)
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	d := got.(*BeadsData)
	if d.Mode != "detail" {
		t.Fatalf("Mode = %q, want detail", d.Mode)
	}
	if d.Issue == nil || d.Issue.ID != "i-open" || d.Issue.Title != "Ready to roll" {
		t.Fatalf("Issue = %+v, want i-open/Ready to roll", d.Issue)
	}
	if strings.Join(d.Issue.Labels, ",") != "backend,urgent" {
		t.Errorf("labels = %v", d.Issue.Labels)
	}
	// i-open is depended on by i-blocked (incoming), and depends on nothing.
	if len(d.DependsOn) != 0 {
		t.Errorf("DependsOn = %v, want none", d.DependsOn)
	}
	if len(d.DependedOnBy) != 1 || d.DependedOnBy[0].IssueID != "i-blocked" {
		t.Errorf("DependedOnBy = %+v, want [i-blocked]", d.DependedOnBy)
	}
	// Only i-open's comment shows in its thread.
	if len(d.Comments) != 1 || d.Comments[0].Author != "alice" || d.Comments[0].Text != "first!" {
		t.Errorf("Comments = %+v, want single alice comment", d.Comments)
	}
}

func TestBeadsBuildDetailOutgoingEdge(t *testing.T) {
	v := &beadsView{}
	q := url.Values{}
	q.Set("issue", "i-blocked")
	got, _ := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", q)
	d := got.(*BeadsData)
	if len(d.DependsOn) != 1 || d.DependsOn[0].IssueID != "i-open" || d.DependsOn[0].Title != "Ready to roll" {
		t.Fatalf("DependsOn = %+v, want [i-open/Ready to roll]", d.DependsOn)
	}
	if d.DependsOn[0].Type != "blocks" || d.DependsOn[0].Closed {
		t.Errorf("edge = %+v, want type=blocks not-closed", d.DependsOn[0])
	}
}

// --- end to end --------------------------------------------------------------

func TestBeadsHandleViewBoard(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 = beadsFixture()
	setViews(t, h, &beadsView{})

	rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	for _, want := range []string{"Rolling", "Lined Up", "Stalled", "Past Stand", "Ready to roll", "beads-marquee"} {
		if !strings.Contains(body, want) {
			t.Errorf("board body missing %q", want)
		}
	}
	// The Tables tab must remain reachable from the view.
	if !strings.Contains(body, "/~alice/db/tree/") {
		t.Errorf("board missing Tables tab link; body=%s", body)
	}
}

func TestBeadsHandleViewDetail(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 = beadsFixture()
	setViews(t, h, &beadsView{})

	rec := h.do("GET", "/~alice/db/view/beads?issue=i-blocked", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, "Waiting") {
		t.Errorf("detail missing issue title; body=%s", body)
	}
	if !strings.Contains(body, "Depends on") || !strings.Contains(body, "i-open") {
		t.Errorf("detail missing dependency edge; body=%s", body)
	}
	if !strings.Contains(body, "Back to the parade") {
		t.Errorf("detail missing back link; body=%s", body)
	}
}

A web/templates/beads.html => web/templates/beads.html +203 -0
@@ 0,0 1,203 @@
{{define "content" -}}
<style>
/* Scoped Mardi Gras palette — inlined here because the scss bundle is not
   rebuilt in dev. Everything is prefixed .beads so it never leaks. */
.beads-marquee {
  display: flex; flex-wrap: wrap; gap: .5rem; align-items: stretch;
  margin-bottom: 1rem;
  border-radius: .4rem; overflow: hidden;
  border: 2px solid #7b2ff7;
  background: linear-gradient(90deg, #2b0a52 0%, #3a1466 50%, #1f5c34 100%);
}
.beads-marquee .bead-stat {
  flex: 1 1 auto; min-width: 7rem; padding: .5rem .75rem; color: #fdf6e3;
  text-align: center;
}
.beads-marquee .bead-stat .n { display: block; font-size: 1.5rem; font-weight: 700; line-height: 1; }
.beads-marquee .bead-stat .l { display: block; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; opacity: .85; }
.beads-marquee .bead-stat.total { background: rgba(245,197,24,.18); }
.beads-marquee .bead-stat .n.rolling { color: #f5c518; }
.beads-marquee .bead-stat .n.lined-up { color: #7be0a0; }
.beads-marquee .bead-stat .n.stalled { color: #c9a3ff; }
.beads-marquee .bead-stat .n.past-stand { color: #c9ccd1; }

.beads-lanes { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: .5rem; }
.beads-lane { flex: 1 0 16rem; min-width: 16rem; }
.beads-lane .lane-head {
  display: flex; justify-content: space-between; align-items: center;
  padding: .4rem .6rem; border-radius: .35rem .35rem 0 0;
  color: #fff; font-weight: 600;
}
.beads-lane .lane-body {
  border: 1px solid rgba(0,0,0,.12); border-top: none;
  border-radius: 0 0 .35rem .35rem; padding: .5rem;
  background: rgba(123,47,247,.03); min-height: 4rem;
}
.beads-lane .lane-count {
  background: rgba(255,255,255,.25); border-radius: 1rem;
  padding: 0 .5rem; font-size: .8rem;
}
.bead-card {
  display: block; background: #fff; border: 1px solid rgba(0,0,0,.12);
  border-left: 4px solid #7b2ff7; border-radius: .3rem;
  padding: .5rem .6rem; margin-bottom: .5rem; text-decoration: none; color: inherit;
}
.bead-card:hover { border-color: #7b2ff7; box-shadow: 0 1px 4px rgba(123,47,247,.25); text-decoration: none; }
.bead-card .card-id { font-family: monospace; font-size: .75rem; color: #7b2ff7; }
.bead-card .card-title { display: block; font-weight: 600; margin: .1rem 0 .25rem; }
.bead-card .card-meta { display: flex; flex-wrap: wrap; gap: .3rem; align-items: center; }
.bead-pill {
  font-size: .68rem; padding: .05rem .4rem; border-radius: 1rem;
  border: 1px solid rgba(0,0,0,.15); background: #f3f3f3;
}
.bead-pill.prio { background: #f5c518; border-color: #d9ad0a; color: #3a2d00; font-weight: 700; }
.bead-pill.type { background: #ece3ff; border-color: #cbb6ff; color: #46248a; }
.bead-pill.assignee { background: #e3f4ea; border-color: #a7d9bd; color: #1d5c37; }
.bead-pill.label { background: #f0f0f0; }
.bead-pill.block { background: #ffe9e0; border-color: #f0b49b; color: #8a3311; }
.beads-empty { color: #888; font-size: .85rem; font-style: italic; padding: .25rem; }

.bead-detail .field-label { font-weight: 600; color: #46248a; }
.bead-detail pre.field-body {
  white-space: pre-wrap; background: #faf7ff; border: 1px solid #e6dbff;
  border-radius: .3rem; padding: .5rem; margin: .25rem 0 1rem;
}
.bead-comment {
  border-left: 3px solid #2e9e4f; background: #f5faf7;
  padding: .4rem .6rem; margin-bottom: .5rem; border-radius: .2rem;
}
.bead-comment .c-head { font-size: .8rem; color: #1d5c37; margin-bottom: .2rem; }
.bead-comment .c-body { white-space: pre-wrap; }
@media (prefers-color-scheme: dark) {
  .bead-card { background: #1e1e24; border-color: #333; color: #e6e6e6; }
  .bead-detail pre.field-body { background: #1e1626; border-color: #3a2a50; color: #e6e6e6; }
  .bead-comment { background: #16211a; }
  .bead-pill { background: #2a2a30; border-color: #444; color: #ddd; }
}
</style>

<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> &middot; parade</h2>
{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "beads" "Ref" .Ref)}}

{{if eq .Data.Mode "detail"}}
{{/* ---------------- detail pane ---------------- */}}
<p><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads">&larr; Back to the parade</a></p>
{{with .Data.Issue}}
<div class="bead-detail">
  <h3>
    <code>{{.ID}}</code> {{.Title}}
    <span class="bead-pill" style="border-color: {{.Accent}}; color: {{.Accent}};">{{.Lane}}</span>
  </h3>
  <div class="card-meta mb-3">
    <span class="bead-pill">status: {{.Status}}</span>
    {{if .Priority}}<span class="bead-pill prio">P{{.Priority}}</span>{{end}}
    {{if .IssueType}}<span class="bead-pill type">{{.IssueType}}</span>{{end}}
    {{if .Assignee}}<span class="bead-pill assignee">@{{.Assignee}}</span>{{end}}
    {{range .Labels}}<span class="bead-pill label">{{.}}</span>{{end}}
  </div>
  <table class="table table-sm">
    <tbody>
      {{if .CreatedBy}}<tr><td class="field-label">Created by</td><td>{{.CreatedBy}}</td></tr>{{end}}
      {{if .Owner}}<tr><td class="field-label">Owner</td><td>{{.Owner}}</td></tr>{{end}}
      {{if .CreatedAt}}<tr><td class="field-label">Created</td><td>{{.CreatedAt}}</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}}
    </tbody>
  </table>
  {{if .Description}}<div class="field-label">Description</div><pre class="field-body">{{.Description}}</pre>{{end}}
  {{if .Design}}<div class="field-label">Design</div><pre class="field-body">{{.Design}}</pre>{{end}}
  {{if .AcceptanceCriteria}}<div class="field-label">Acceptance criteria</div><pre class="field-body">{{.AcceptanceCriteria}}</pre>{{end}}
  {{if .Notes}}<div class="field-label">Notes</div><pre class="field-body">{{.Notes}}</pre>{{end}}
</div>

<div class="row">
  <div class="col-md-6">
    <h4>Depends on</h4>
    {{if $.Data.DependsOn}}
    <ul>
      {{range $.Data.DependsOn}}
      <li>
        <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.IssueID | urlquery}}"><code>{{.IssueID}}</code></a>
        {{if .Title}}— {{.Title}}{{end}}
        <span class="bead-pill">{{.Type}}</span>
        {{if .Closed}}<span class="bead-pill">closed</span>{{else}}<span class="bead-pill block">{{.Status}}</span>{{end}}
      </li>
      {{end}}
    </ul>
    {{else}}<p class="beads-empty">No outgoing dependencies.</p>{{end}}
  </div>
  <div class="col-md-6">
    <h4>Depended on by</h4>
    {{if $.Data.DependedOnBy}}
    <ul>
      {{range $.Data.DependedOnBy}}
      <li>
        <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.IssueID | urlquery}}"><code>{{.IssueID}}</code></a>
        {{if .Title}}— {{.Title}}{{end}}
        <span class="bead-pill">{{.Type}}</span>
      </li>
      {{end}}
    </ul>
    {{else}}<p class="beads-empty">Nothing depends on this issue.</p>{{end}}
  </div>
</div>

<h4>Comments</h4>
{{if $.Data.Comments}}
{{range $.Data.Comments}}
<div class="bead-comment">
  <div class="c-head"><strong>{{.Author}}</strong> {{if .CreatedAt}}&middot; {{.CreatedAt}}{{end}}</div>
  <div class="c-body">{{.Text}}</div>
</div>
{{end}}
{{else}}<p class="beads-empty">No comments.</p>{{end}}
{{else}}
<div class="alert alert-warning">Issue not found.</div>
{{end}}

{{else}}
{{/* ---------------- board ---------------- */}}
{{if .Data.Truncated}}
<div class="alert alert-warning">Showing the first {{.Data.Total}} of {{.Data.ShownOf}} issues.</div>
{{end}}

<div class="beads-marquee">
  <div class="bead-stat"><span class="n rolling">{{.Data.Counts.Rolling}}</span><span class="l">Rolling</span></div>
  <div class="bead-stat"><span class="n lined-up">{{.Data.Counts.LinedUp}}</span><span class="l">Lined Up</span></div>
  <div class="bead-stat"><span class="n stalled">{{.Data.Counts.Stalled}}</span><span class="l">Stalled</span></div>
  <div class="bead-stat"><span class="n past-stand">{{.Data.Counts.PastStand}}</span><span class="l">Past Stand</span></div>
  <div class="bead-stat total"><span class="n">{{.Data.Counts.Total}}</span><span class="l">Total</span></div>
</div>

<div class="beads-lanes">
  {{range .Data.Lanes}}
  {{$accent := .Accent}}
  <div class="beads-lane">
    <div class="lane-head" style="background: {{.Accent}};">
      <span>{{.Name}}</span>
      <span class="lane-count">{{len .Issues}}</span>
    </div>
    <div class="lane-body">
      {{range .Issues}}
      <a class="bead-card" style="border-left-color: {{$accent}};"
         href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.ID | urlquery}}">
        <span class="card-id">{{.ID}}</span>
        <span class="card-title">{{.Title}}</span>
        <span class="card-meta">
          {{if .PriorityLabel}}<span class="bead-pill prio">{{.PriorityLabel}}</span>{{end}}
          {{if .Type}}<span class="bead-pill type">{{.Type}}</span>{{end}}
          {{if .Assignee}}<span class="bead-pill assignee">@{{.Assignee}}</span>{{end}}
          {{range .Labels}}<span class="bead-pill label">{{.}}</span>{{end}}
          {{if .BlockedBy}}<span class="bead-pill block">&#128679; {{.BlockedBy}}</span>{{end}}
          {{if .Blocks}}<span class="bead-pill">blocks {{.Blocks}}</span>{{end}}
        </span>
      </a>
      {{else}}
      <p class="beads-empty">Empty lane.</p>
      {{end}}
    </div>
  </div>
  {{end}}
</div>
{{end}}
{{- end}}