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{
// Accents are muted Mardi Gras hues (gold / green / violet / gray)
// chosen to read on both the light and dark SourceHut themes. They
// are applied by the template as thin accents (card border, lane
// underline, tinted chips), never as body text, so contrast holds.
{Name: "Rolling", Slug: "rolling", Accent: "#c9930a", Issues: rolling},
{Name: "Lined Up", Slug: "lined-up", Accent: "#2f9e44", Issues: linedUp},
{Name: "Stalled", Slug: "stalled", Accent: "#9c36b5", Issues: stalled},
{Name: "Past Stand", Slug: "past-stand", Accent: "#868e96", 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", "#868e96"
case "in_progress":
return "Rolling", "#c9930a"
default:
return "Lined Up", "#2f9e44"
}
}
// 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
}