~bigbes/sr-ht-dolt

fe616c89c202c97268fe09ae2311c8cf30ca9059 — Eugene Blikh 5 days ago aff7268
beads: add a one-column stream layout beside the board

?layout=stream renders the same filtered set, the same buckets and the same
cards as one column of sections instead of four lanes side by side. It is a
layout of the Beads view and not a fifth tab: filters, the ready toggle, the
search box and ?issue= behave exactly as they do on the board.

A column read top to bottom can afford one sort per section, because each
section answers a different question: Rolling by started_at desc (what was
picked up last is what is being worked on), Lined Up ready-first then priority
then oldest, Stalled by blocker count (one blocker away is nearer than five),
Past Stand by closed_at desc. started_at and closed_at join the card model as
sort keys and are not displayed; a row missing one sorts last, because an unset
timestamp is not a very old one.

Past Stand opens collapsed in a <details> — the largest and least actionable
section, closed without a line of JavaScript. The sections are derived from the
finished lanes rather than bucketed again, so the section counts cannot drift
from the marquee, and the lanes keep the board order the board renders.

The Board/Stream toggle rebuilds the current query with layout replaced, via a
new withQuery template func, so every active filter survives the switch.
M beads/build.go => beads/build.go +13 -1
@@ 13,7 13,9 @@ import (
// --- build -------------------------------------------------------------------

// Build reads the issue graph and produces either the board or, when ?issue=
// names an issue, that issue's detail pane.
// names an issue, that issue's detail pane. The board is rendered in one of two
// layouts, selected by ?layout= (see parseLayout): four lanes, or the one-column
// stream in Sections. Both are the same filtered set in the same buckets.
func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values) (*Data, error) {
	issues, issuesTotal, err := readRows(ctx, sess, ref, "issues")
	if err != nil {


@@ 135,6 137,8 @@ func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values
			BlockedBy: blockedByCount[id],
			Blocks:    blocksCount[id],
			Ready:     ready,
			StartedAt: cell(issueCols, r, "started_at"),
			ClosedAt:  cell(issueCols, r, "closed_at"),
		}

		switch {


@@ 178,6 182,14 @@ func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values
		ShownOf:    shownOf,
		Filter:     filter,
		FilterOpts: opts,
		Layout:     parseLayout(query.Get("layout")),
		Query:      query,
	}
	// The stream is the lanes just built, re-sorted for a top-to-bottom read —
	// derived from them rather than bucketed again, so the section counts cannot
	// drift from the marquee.
	if data.Layout == LayoutStream {
		data.Sections = streamSections(data.Lanes, created)
	}
	return data, nil
}

M beads/model.go => beads/model.go +36 -0
@@ 1,10 1,19 @@
package beads

import (
	"net/url"
	"strconv"
	"strings"
)

// Layout names the shape the board mode is rendered in, from ?layout=. It is a
// layout of one view and not a second view: the same filtered set, the same
// buckets, the same cards, either four lanes side by side or one column.
const (
	LayoutBoard  = "board"  // four lanes side by side; the default
	LayoutStream = "stream" // one column, sections stacked in parade order
)

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

// Data is the opaque .Data value handed to beads.html. Mode discriminates


@@ 14,7 23,9 @@ type Data struct {
	Mode string // "board" | "detail" | "epic"

	// board mode
	Layout     string // LayoutBoard | LayoutStream; "" in the detail modes
	Lanes      []Lane
	Sections   []Section // LayoutStream only: the same buckets, read top to bottom
	Counts     Counts
	Total      int           // issues placed on the board (after filtering)
	Truncated  bool          // an input table exceeded Max and was clipped


@@ 22,6 33,13 @@ type Data struct {
	Filter     Filter        // active board filters (sticky form state)
	FilterOpts FilterOptions // distinct values for the filter dropdowns

	// Query is the request's query as parsed, carried so the layout toggle can
	// rebuild this exact URL with one key replaced (web's withQuery). The view
	// envelope does not carry the query, and rebuilding it from Filter would
	// silently drop everything this projection does not model — ?ref= among
	// them. Set in board mode only.
	Query url.Values

	// detail / epic modes
	Issue        *Issue
	DependsOn    []Edge     // this issue depends on … (outgoing, direct)


@@ 100,6 118,16 @@ type Lane struct {
	Issues []Card
}

// Section is one section of the stream layout: a lane, plus the two things a
// section header in a single column needs that a lane header does not — whether
// it opens collapsed, and a one-line hint at the order its issues are in (the
// stream sorts each section differently, so the order is worth stating).
type Section struct {
	Lane             // Name, Slug, Accent, Issues — the same bucketing as the board
	Collapsed bool   // rendered inside <details> with no open attribute
	Note      string // "" or a one-line hint, e.g. "closed, newest first"
}

// Counts is the marquee: per-lane totals plus the grand total.
type Counts struct {
	Rolling   int


@@ 121,6 149,14 @@ type Card struct {
	Blocks    int    // # of deps pointing at this issue (things waiting on it)
	Ready     bool   // actionable now: open, unblocked, not deferred/template (bd's `ready` set)
	Category  string // open | in_progress | closed (used by the milestones view)

	// Sort keys for the stream layout, which orders Rolling by when work was
	// picked up and Past Stand by when it finished. Neither is rendered on a
	// card — the timestamps are on the detail pane, and a card already carries
	// as much metadata as a glance holds. Stored as read ("YYYY-MM-DD HH:MM:SS"),
	// so a lexical compare is a time compare; "" means unset and sorts last.
	StartedAt string
	ClosedAt  string
}

// PriorityLabel renders the numeric priority as a P-pill label ("P0".."P3"),

A beads/stream.go => beads/stream.go +157 -0
@@ 0,0 1,157 @@
package beads

import (
	"sort"
	"strings"
)

// --- the stream layout -------------------------------------------------------
//
// The board is four lanes side by side and sorts every one of them the same way,
// which is right for comparing lanes. A single column is read top to bottom, and
// then each section answers a different question, so each gets its own order.

// parseLayout reads ?layout=. Anything other than "stream" — absent, misspelled,
// or a layout that no longer exists — is the board: a stale link renders the
// default page rather than an error.
func parseLayout(v string) string {
	if strings.EqualFold(strings.TrimSpace(v), LayoutStream) {
		return LayoutStream
	}
	return LayoutBoard
}

// streamSections turns the finished lanes into the stream's sections, in parade
// order. Bucketing is not repeated: each section is the lane it names, so one
// issue lands in exactly one section and the section counts are the marquee's
// counts by construction. Only the order inside a section changes, on a copy —
// the lanes keep the board order the board renders.
//
// created is the id → created_at map the board sort already built; Lined Up is
// the one section that orders by it.
func streamSections(lanes []Lane, created map[string]string) []Section {
	sections := make([]Section, 0, len(lanes))
	for _, lane := range lanes {
		sec := Section{Lane: lane}
		sec.Issues = append([]Card(nil), lane.Issues...)
		// The lane set is built in Build and this switch covers it; a lane outside
		// it would keep the board's order and carry no note, which is the honest
		// rendering of "this section has no reading order of its own".
		switch lane.Slug {
		case "rolling":
			sortRolling(sec.Issues)
			sec.Note = "most recently started first"
		case "lined-up":
			sortLinedUp(sec.Issues, created)
			sec.Note = "ready first, then priority"
		case "stalled":
			sortStalled(sec.Issues)
			sec.Note = "fewest blockers first"
		case "past-stand":
			sortPastStand(sec.Issues)
			sec.Note = "closed, newest first"
			// The largest section and the least actionable one: it opens closed so
			// the three sections above it stay reachable without scrolling past a
			// log. <details> does that with no JavaScript.
			sec.Collapsed = true
		}
		sections = append(sections, sec)
	}
	return sections
}

// sortRolling orders in-progress work by when it was picked up, most recent
// first — what was started last is what is actually being worked on. Ties fall
// back to priority, then id.
func sortRolling(cards []Card) {
	sort.SliceStable(cards, func(i, j int) bool {
		if c := cmpTimeDesc(cards[i].StartedAt, cards[j].StartedAt); c != 0 {
			return c < 0
		}
		if pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority); pi != pj {
			return pi < pj
		}
		return cards[i].ID < cards[j].ID
	})
}

// sortLinedUp leads with the ready set: this is the "what can I take" section,
// and an issue that is actionable now belongs above one that is merely open.
// Then priority, then oldest first, then id.
func sortLinedUp(cards []Card, created map[string]string) {
	sort.SliceStable(cards, func(i, j int) bool {
		if ri, rj := cards[i].Ready, cards[j].Ready; ri != rj {
			return ri
		}
		if pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority); pi != pj {
			return pi < pj
		}
		if c := cmpTimeAsc(created[cards[i].ID], created[cards[j].ID]); c != 0 {
			return c < 0
		}
		return cards[i].ID < cards[j].ID
	})
}

// sortStalled orders by how far each issue is from moving: one blocker away is
// nearer than five. Then priority, then id.
func sortStalled(cards []Card) {
	sort.SliceStable(cards, func(i, j int) bool {
		if bi, bj := cards[i].BlockedBy, cards[j].BlockedBy; bi != bj {
			return bi < bj
		}
		if pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority); pi != pj {
			return pi < pj
		}
		return cards[i].ID < cards[j].ID
	})
}

// sortPastStand orders the log by when work finished, most recent on top. There
// is no priority tiebreak: nothing here is prioritised any more.
func sortPastStand(cards []Card) {
	sort.SliceStable(cards, func(i, j int) bool {
		if c := cmpTimeDesc(cards[i].ClosedAt, cards[j].ClosedAt); c != 0 {
			return c < 0
		}
		return cards[i].ID < cards[j].ID
	})
}

// cmpTimeAsc compares two stored timestamps oldest first, with the unset value
// LAST rather than first. beads timestamps share the "YYYY-MM-DD HH:MM:SS"
// shape, so a lexical compare is a time compare — but "" is lexically smaller
// than every date, and an issue whose start or close was never recorded is not
// the oldest issue in the section. It is the one nothing is known about, and it
// belongs at the bottom.
func cmpTimeAsc(a, b string) int {
	switch {
	case a == b:
		return 0
	case a == "":
		return 1
	case b == "":
		return -1
	case a < b:
		return -1
	default:
		return 1
	}
}

// cmpTimeDesc is cmpTimeAsc reversed for the present values, keeping the unset
// one last (a plain negation would float it to the top).
func cmpTimeDesc(a, b string) int {
	switch {
	case a == b:
		return 0
	case a == "":
		return 1
	case b == "":
		return -1
	case a > b:
		return -1
	default:
		return 1
	}
}

A beads/stream_test.go => beads/stream_test.go +295 -0
@@ 0,0 1,295 @@
package beads

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

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

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

// streamFixture models a parade with several issues per lane, each lane carrying
// the distinctions its stream order is supposed to make:
//
//   - Rolling   : two different started_at stamps and one issue that never got
//     one, with the priorities set against the expected order so
//     the timestamp is what is actually being tested.
//   - Lined Up  : ready and non-ready (a template) issues, two priorities, two
//     created_at stamps and one missing.
//   - Stalled   : three issues with 1, 2 and 3 blockers, priorities again set
//     against the expected order.
//   - Past Stand: two closed_at stamps and one issue closed without one.
func streamFixture() *fakeSession {
	issues := &browse.RowPage{
		Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "started_at", "closed_at", "is_blocked", "is_template"},
		Rows: [][]string{
			// Rolling: started most recently first, unstamped last.
			{"r-late", "Picked up in March", "in_progress", "3", "task", "alice", "2024-01-01", "2024-03-05 10:00:00", "NULL", "0", "0"},
			{"r-early", "Picked up in January", "in_progress", "0", "task", "alice", "2024-01-01", "2024-01-05 10:00:00", "NULL", "0", "0"},
			{"r-none", "Never stamped", "in_progress", "0", "task", "alice", "2024-01-01", "NULL", "NULL", "0", "0"},
			// Lined Up: ready leads, then priority, then oldest first.
			{"l-p1-old", "P1 from January", "open", "1", "feature", "bob", "2024-01-01", "NULL", "NULL", "0", "0"},
			{"l-p1-new", "P1 from February", "open", "1", "feature", "bob", "2024-02-01", "NULL", "NULL", "0", "0"},
			{"l-p2", "P2", "open", "2", "feature", "bob", "2024-01-01", "NULL", "NULL", "0", "0"},
			{"l-p3-dated", "P3 with a date", "open", "3", "feature", "bob", "2024-01-02", "NULL", "NULL", "0", "0"},
			{"l-p3-nodate", "P3 with no date", "open", "3", "feature", "bob", "NULL", "NULL", "NULL", "0", "0"},
			{"l-template", "A P0 scaffold, never ready", "open", "0", "feature", "bob", "2024-01-01", "NULL", "NULL", "0", "1"},
			// Stalled: blocked by 1, 2 and 3 open issues (see deps below).
			{"s-one", "One blocker away", "open", "3", "bug", "carol", "2024-01-01", "NULL", "NULL", "0", "0"},
			{"s-two", "Two blockers away", "open", "1", "bug", "carol", "2024-01-01", "NULL", "NULL", "0", "0"},
			{"s-three", "Three blockers away", "open", "0", "bug", "carol", "2024-01-01", "NULL", "NULL", "0", "0"},
			// Past Stand: most recently closed on top, unstamped last.
			{"p-new", "Closed in May", "closed", "1", "chore", "dave", "2024-01-01", "NULL", "2024-05-01 08:00:00", "0", "0"},
			{"p-old", "Closed in February", "closed", "0", "chore", "dave", "2024-01-01", "NULL", "2024-02-01 08:00:00", "0", "0"},
			{"p-none", "Closed without a stamp", "closed", "0", "chore", "dave", "2024-01-01", "NULL", "NULL", "0", "0"},
		},
		Total: 15,
	}
	deps := &browse.RowPage{
		Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
		Rows: [][]string{
			{"d1", "s-one", "l-p2", "blocks"},
			{"d2", "s-two", "l-p2", "blocks"},
			{"d3", "s-two", "l-p1-old", "blocks"},
			{"d4", "s-three", "l-p2", "blocks"},
			{"d5", "s-three", "l-p1-old", "blocks"},
			{"d6", "s-three", "l-p1-new", "blocks"},
		},
		Total: 6,
	}
	statuses := &browse.RowPage{
		Columns: []string{"name", "category"},
		Rows: [][]string{
			{"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"},
		},
		Total: 3,
	}
	return &fakeSession{
		rowsByTable: map[string]*browse.RowPage{
			"issues":          issues,
			"dependencies":    deps,
			"custom_statuses": statuses,
		},
	}
}

// sectionBySlug finds a section of a built stream by its slug.
func sectionBySlug(d *Data, slug string) *Section {
	for i := range d.Sections {
		if d.Sections[i].Slug == slug {
			return &d.Sections[i]
		}
	}
	return nil
}

// sectionIDs lists the ids of a section's cards, in the order they render.
func sectionIDs(s *Section) []string {
	if s == nil {
		return nil
	}
	out := make([]string, len(s.Issues))
	for i, c := range s.Issues {
		out[i] = c.ID
	}
	return out
}

// --- layout selection --------------------------------------------------------

func TestBeadsLayoutSelection(t *testing.T) {
	cases := []struct {
		name  string
		query url.Values
		want  string
	}{
		{"absent", url.Values{}, LayoutBoard},
		{"empty", url.Values{"layout": {""}}, LayoutBoard},
		{"stream", url.Values{"layout": {"stream"}}, LayoutStream},
		{"stream-cased", url.Values{"layout": {"Stream"}}, LayoutStream},
		{"unknown", url.Values{"layout": {"parade"}}, LayoutBoard},
		{"board", url.Values{"layout": {"board"}}, LayoutBoard},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			d, err := Build(context.Background(), streamFixture(), "main", tc.query)
			require.NoError(t, err)
			assert.Equal(t, "board", d.Mode, "the layout is a shape of the board, not a mode")
			assert.Equal(t, tc.want, d.Layout)
			if tc.want == LayoutStream {
				assert.Len(t, d.Sections, 4)
			} else {
				assert.Empty(t, d.Sections, "the board builds no sections")
			}
			// The lanes are built either way: the marquee and the board read them.
			assert.Len(t, d.Lanes, 4)
		})
	}
}

// An issue detail wins over any layout — ?issue= short-circuits the board.
func TestBeadsStreamDetailWins(t *testing.T) {
	d, err := Build(context.Background(), streamFixture(), "main",
		url.Values{"layout": {"stream"}, "issue": {"l-p2"}})
	require.NoError(t, err)
	assert.Equal(t, "detail", d.Mode)
	assert.Empty(t, d.Sections)
	require.NotNil(t, d.Issue)
	assert.Equal(t, "l-p2", d.Issue.ID)
}

// --- bucketing ---------------------------------------------------------------

// The stream is the board's buckets in another shape: for the same filters, each
// section holds exactly the issues its lane holds, and both agree with the
// marquee counts.
func TestBeadsStreamSectionsMatchLanes(t *testing.T) {
	filters := []struct {
		name  string
		query url.Values
	}{
		{"no-filter", url.Values{}},
		{"type", url.Values{"type": {"bug"}}},
		{"assignee", url.Values{"assignee": {"bob"}}},
		{"priority", url.Values{"priority": {"0"}}},
		{"ready", url.Values{"ready": {"1"}}},
		{"query", url.Values{"q": {"closed"}}},
		{"combined-empty", url.Values{"type": {"bug"}, "assignee": {"bob"}}},
	}
	for _, tc := range filters {
		t.Run(tc.name, func(t *testing.T) {
			board, err := Build(context.Background(), streamFixture(), "main", tc.query)
			require.NoError(t, err)

			stream := url.Values{"layout": {"stream"}}
			for k, vs := range tc.query {
				stream[k] = vs
			}
			s, err := Build(context.Background(), streamFixture(), "main", stream)
			require.NoError(t, err)

			require.Len(t, s.Sections, len(board.Lanes))
			total := 0
			for i, lane := range board.Lanes {
				sec := sectionBySlug(s, lane.Slug)
				require.NotNil(t, sec, "section %s missing", lane.Slug)
				assert.Equal(t, lane.Name, sec.Name)
				assert.Equal(t, lane.Accent, sec.Accent)
				assert.ElementsMatch(t, cardIDs(&board.Lanes[i]), sectionIDs(sec),
					"section %s holds a different set than its lane", lane.Slug)
				total += len(sec.Issues)
			}
			// …and the marquee is the same count, section by section.
			assert.Equal(t, s.Counts.Rolling, len(sectionBySlug(s, "rolling").Issues))
			assert.Equal(t, s.Counts.LinedUp, len(sectionBySlug(s, "lined-up").Issues))
			assert.Equal(t, s.Counts.Stalled, len(sectionBySlug(s, "stalled").Issues))
			assert.Equal(t, s.Counts.PastStand, len(sectionBySlug(s, "past-stand").Issues))
			assert.Equal(t, s.Counts.Total, total)
			assert.Equal(t, board.Counts, s.Counts)
		})
	}
}

// Sorting a section must not reorder the lane it came from: the board renders
// the lanes, and it renders the same page as before this layout existed.
func TestBeadsStreamLeavesLanesInBoardOrder(t *testing.T) {
	board, err := Build(context.Background(), streamFixture(), "main", url.Values{})
	require.NoError(t, err)
	stream, err := Build(context.Background(), streamFixture(), "main", url.Values{"layout": {"stream"}})
	require.NoError(t, err)

	for i, lane := range board.Lanes {
		assert.Equal(t, cardIDs(&board.Lanes[i]), cardIDs(&stream.Lanes[i]),
			"lane %s changed order in stream mode", lane.Slug)
	}
	// And the stream really did reorder something, or the check above is vacuous.
	assert.NotEqual(t, cardIDs(laneBySlug(stream, "rolling")), sectionIDs(sectionBySlug(stream, "rolling")))
}

// --- per-section order -------------------------------------------------------

func TestBeadsStreamSectionOrder(t *testing.T) {
	d, err := Build(context.Background(), streamFixture(), "main", url.Values{"layout": {"stream"}})
	require.NoError(t, err)

	cases := []struct {
		slug string
		want []string
		why  string
	}{
		{
			"rolling",
			[]string{"r-late", "r-early", "r-none"},
			"started_at desc; the unstamped issue sorts last despite its P0",
		},
		{
			"lined-up",
			[]string{"l-p1-old", "l-p1-new", "l-p2", "l-p3-dated", "l-p3-nodate", "l-template"},
			"ready first (the P0 template is not ready), then priority, then created_at asc with the undated one last",
		},
		{
			"stalled",
			[]string{"s-one", "s-two", "s-three"},
			"fewest blockers first, against the priorities",
		},
		{
			"past-stand",
			[]string{"p-new", "p-old", "p-none"},
			"closed_at desc; closed without a stamp sorts last",
		},
	}
	for _, tc := range cases {
		t.Run(tc.slug, func(t *testing.T) {
			assert.Equal(t, tc.want, sectionIDs(sectionBySlug(d, tc.slug)), tc.why)
		})
	}
}

// The timestamps are sort keys read off the issue row; nothing else reads them,
// so a wrong column name would be invisible without this.
func TestBeadsStreamCardTimestamps(t *testing.T) {
	d, err := Build(context.Background(), streamFixture(), "main", url.Values{"layout": {"stream"}})
	require.NoError(t, err)

	rolling := sectionBySlug(d, "rolling")
	require.NotNil(t, rolling)
	assert.Equal(t, "2024-03-05 10:00:00", rolling.Issues[0].StartedAt)
	assert.Empty(t, rolling.Issues[2].StartedAt, "a NULL cell reads as unset, not as the string NULL")

	past := sectionBySlug(d, "past-stand")
	require.NotNil(t, past)
	assert.Equal(t, "2024-05-01 08:00:00", past.Issues[0].ClosedAt)
	assert.Empty(t, past.Issues[2].ClosedAt)
}

// --- section chrome ----------------------------------------------------------

func TestBeadsStreamCollapsedAndNotes(t *testing.T) {
	d, err := Build(context.Background(), streamFixture(), "main", url.Values{"layout": {"stream"}})
	require.NoError(t, err)

	collapsed := map[string]bool{}
	for _, s := range d.Sections {
		collapsed[s.Slug] = s.Collapsed
		assert.NotEmpty(t, s.Note, "section %s should state the order it is in", s.Slug)
	}
	assert.True(t, collapsed["past-stand"], "Past Stand opens collapsed")
	for _, slug := range []string{"rolling", "lined-up", "stalled"} {
		assert.False(t, collapsed[slug], "%s must stay open", slug)
	}

	// Parade order, top to bottom.
	assert.Equal(t, []string{"rolling", "lined-up", "stalled", "past-stand"},
		[]string{d.Sections[0].Slug, d.Sections[1].Slug, d.Sections[2].Slug, d.Sections[3].Slug})
}

// The query is carried through so the layout toggle can rebuild this URL.
func TestBeadsStreamCarriesQuery(t *testing.T) {
	q := url.Values{"layout": {"stream"}, "type": {"bug"}, "ready": {"1"}}
	d, err := Build(context.Background(), streamFixture(), "main", q)
	require.NoError(t, err)
	assert.Equal(t, q, d.Query)
}

M web/beads.go => web/beads.go +5 -2
@@ 11,8 11,11 @@ import (

// 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.
// per-issue detail pane reachable via ?issue=<id>. ?layout=stream draws the same
// filtered set as one column of sections instead — a layout of this view and not
// a second one, which is why it is a query parameter and not another tab. All
// data is read through the BrowseSession surface (Rows/Tables) — there is no SQL
// engine behind it.
//
// The reading itself is not here: the fingerprint, the lane bucketing, the ready
// rule and the whole view model live in the beads package, which the MCP surface

M web/beads_test.go => web/beads_test.go +134 -0
@@ 298,6 298,140 @@ func TestBeadsDetailShowsCloseReason(t *testing.T) {
	}
}

// --- the stream layout -------------------------------------------------------

func TestBeadsStreamRender(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?layout=stream", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("stream: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	// One column of sections, the board's lanes gone, and the same cards in it.
	for _, want := range []string{
		`class="beads-stream"`, `class="stream-head"`, `class="stream-body"`,
		"Rolling", "Lined Up", "Stalled", "Past Stand",
		"Ready to roll", "ready-dot", `class="bead-row"`,
	} {
		if !strings.Contains(body, want) {
			t.Errorf("stream body missing %q", want)
		}
	}
	if strings.Contains(body, `class="beads-lanes"`) {
		t.Errorf("stream body should not render the board's lane row; body=%s", body)
	}
	// Past Stand is the one collapsed section: a <details> with no open attribute.
	if !strings.Contains(body, `<details class="beads-section past-stand">`) {
		t.Errorf("Past Stand should render as a collapsed <details>; body=%s", body)
	}
	if strings.Contains(body, "<details open") {
		t.Errorf("no section should render as an open <details>; body=%s", body)
	}
	// The filter form must carry the layout, or filtering would drop back to the
	// board; the toggle marks Stream as current and links to the board.
	if !strings.Contains(body, `<input type="hidden" name="layout" value="stream">`) {
		t.Errorf("stream form missing the layout carrier; body=%s", body)
	}
	if !strings.Contains(body, `<span class="current" aria-current="page">Stream</span>`) {
		t.Errorf("toggle should mark Stream as current; body=%s", body)
	}
	if !strings.Contains(body, `href="/~alice/db/view/beads">Board</a>`) {
		t.Errorf("toggle should link back to an unfiltered board; body=%s", body)
	}
	// No JavaScript is added by this layout.
	if strings.Contains(body, "<script") {
		t.Errorf("the stream layout must add no script; body=%s", body)
	}
}

// The board is what an unknown ?layout= renders — a stale link answers with the
// default page, not an error.
func TestBeadsUnknownLayoutRendersBoard(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?layout=parade", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("unknown layout: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, `class="beads-lanes"`) {
		t.Errorf("an unknown layout should render the board; body=%s", body)
	}
	if strings.Contains(body, `class="beads-stream"`) {
		t.Errorf("an unknown layout rendered the stream; body=%s", body)
	}
	// The toggle offers the stream, carrying the unknown value along untouched —
	// the query is rebuilt with one key replaced, not rewritten.
	if !strings.Contains(body, `href="/~alice/db/view/beads?layout=stream"`) {
		t.Errorf("board toggle should link to the stream; body=%s", body)
	}
}

// Switching layouts must keep the page pointed at the same issues: every active
// filter survives the toggle, in both directions.
func TestBeadsLayoutToggleKeepsFilters(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{})

	// Board → Stream: the toggle adds layout=stream to everything already set.
	rec := h.do("GET", "/~alice/db/view/beads?q=ready&type=feature&priority=1&assignee=alice&label=urgent&ready=1&ref=main", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("filtered board: got %d; body=%s", rec.Code, rec.Body.String())
	}
	wantStream := `href="/~alice/db/view/beads?assignee=alice&amp;label=urgent&amp;layout=stream&amp;priority=1&amp;q=ready&amp;ready=1&amp;ref=main&amp;type=feature"`
	if !strings.Contains(rec.Body.String(), wantStream) {
		t.Errorf("stream link should carry every filter, want %s; body=%s", wantStream, rec.Body.String())
	}

	// Stream → Board: the same query with layout dropped, nothing else touched.
	rec = h.do("GET", "/~alice/db/view/beads?q=ready&type=feature&priority=1&assignee=alice&label=urgent&ready=1&ref=main&layout=stream", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("filtered stream: got %d; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	wantBoard := `href="/~alice/db/view/beads?assignee=alice&amp;label=urgent&amp;priority=1&amp;q=ready&amp;ready=1&amp;ref=main&amp;type=feature"`
	if !strings.Contains(body, wantBoard) {
		t.Errorf("board link should carry every filter and drop the layout, want %s; body=%s", wantBoard, body)
	}
	// The filters are still applied and still sticky in the form.
	if !strings.Contains(body, `value="feature" selected`) || !strings.Contains(body, `name="q" value="ready"`) {
		t.Errorf("filtered stream lost its sticky form state; body=%s", body)
	}
	// Clearing filters keeps the layout: it is how the page is read, not a filter.
	if !strings.Contains(body, `href="/~alice/db/view/beads?ref=main&amp;layout=stream">Clear</a>`) {
		t.Errorf("Clear should keep ref and layout; body=%s", body)
	}
}

// ?issue= wins over any layout: a link to a card is a link to a card.
func TestBeadsStreamIssueDetailWins(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?layout=stream&issue=i-blocked", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("detail under layout=stream: got %d; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, "Waiting") || !strings.Contains(body, "Back to the parade") {
		t.Errorf("layout=stream should not disturb the detail pane; body=%s", body)
	}
	if strings.Contains(body, `class="beads-stream"`) {
		t.Errorf("the detail pane rendered a stream; body=%s", body)
	}
}

func TestBeadsEpicViewRender(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})

M web/templates.go => web/templates.go +31 -0
@@ 95,6 95,10 @@ func templateFuncs(icons map[string]template.HTML) template.FuncMap {
	// doltHost derives the host:port a `dolt login --auth-endpoint` expects
	// from our origin URL (defaulting to :443 for https).
	m["doltHost"] = doltHost
	// withQuery rebuilds a request's query with one key replaced, for a link
	// that switches one dimension of a page — the beads board/stream toggle —
	// without re-listing the filters that are already set.
	m["withQuery"] = withQuery

	return m
}


@@ 115,6 119,33 @@ func doltHost(origin string) string {
	return u.Host + ":443"
}

// withQuery renders q with key set to value — or removed, when value is empty —
// as a query string ready to append to a path: it carries its own leading "?"
// and is empty when nothing is left. q itself is not modified; it is the live
// request's query, and a template func that mutated it would change the page
// rendering it.
//
// The point is that everything else in q survives. A link that spelled out the
// keys it knows about would quietly drop ?ref= and any filter added later,
// which is how "switch to the stream" turns into "switch to the stream of
// something else".
func withQuery(q url.Values, key, value string) string {
	next := make(url.Values, len(q)+1)
	for k, vs := range q {
		next[k] = append([]string(nil), vs...)
	}
	if value == "" {
		next.Del(key)
	} else {
		next.Set(key, value)
	}
	enc := next.Encode()
	if enc == "" {
		return ""
	}
	return "?" + enc
}

// humanizeSize renders a byte count with binary (1024) units.
func humanizeSize(n uint64) string {
	const unit = 1024

M web/templates/beads.html => web/templates/beads.html +97 -2
@@ 48,6 48,13 @@
.beads-ready-toggle { display: inline-flex; align-items: center; gap: .3rem; font-size: .82rem; color: var(--bd-fg); }
.ready-dot { margin-right: .25rem; }

/* layout toggle: the same page in two shapes, so it is a pair of words and not
   a tab — the current one is plain text, the other one a link */
.beads-layout { display: flex; gap: .35rem; align-items: baseline; font-size: .82rem; margin: -.5rem 0 1rem; }
.beads-layout .l { color: var(--bd-muted); }
.beads-layout .sep { color: var(--bd-muted); }
.beads-layout .current { color: var(--bd-fg); font-weight: 700; }

/* transitive dependency tree: indent by --depth via a hairline guide */
.dep-graph { margin: 0 0 1.25rem; }
.dep-tree { list-style: none; padding-left: 0; margin: 0 0 .5rem; }


@@ 78,6 85,28 @@
.beads-lane .lane-head .count { margin-left: auto; color: var(--bd-muted); font-weight: 400; font-size: .85rem; }
.beads-lane .lane-body { border: 1px solid var(--bd-border); border-top: none; }

/* stream: the same lanes stacked as one readable column. The heads are the lane
   heads (2px accent cap, swatch, count) made sticky, so the section a row
   belongs to stays named while its issues scroll past. */
.beads-stream { max-width: 52rem; }
.beads-stream .beads-section { margin-bottom: .75rem; }
.beads-stream .stream-head {
  display: flex; align-items: baseline; gap: .4rem; padding: .3rem .5rem; font-weight: 700;
  background: var(--bd-panel); color: var(--bd-fg);
  border: 1px solid var(--bd-border); border-top: 2px solid var(--lane);
  position: sticky; top: 0;
}
/* A flex summary drops the browser's disclosure marker, and a collapsed section
   with no affordance reads as an empty one — so draw the triangle back. */
.beads-stream summary.stream-head { cursor: pointer; list-style: none; }
.beads-stream summary.stream-head::-webkit-details-marker { display: none; }
.beads-stream summary.stream-head::before { content: "\25B8"; color: var(--bd-muted); }
.beads-stream details[open] > summary.stream-head::before { content: "\25BE"; }
.beads-stream .stream-head .swatch { align-self: center; }
.beads-stream .stream-head .note { color: var(--bd-muted); font-weight: 400; font-size: .72rem; }
.beads-stream .stream-head .count { margin-left: auto; color: var(--bd-muted); font-weight: 400; font-size: .85rem; }
.beads-stream .stream-body { border: 1px solid var(--bd-border); border-top: none; }

/* issue rows: todo.sr.ht ticket-list feel — hairline separated, no radius */
.bead-row { display: block; padding: .3rem .5rem; text-decoration: none; color: var(--bd-fg); border-top: 1px solid var(--bd-border); }
.bead-row:first-child { border-top: none; }


@@ 336,6 365,9 @@ pre.field-body {

<form class="beads-filter" method="get" action="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads">
  {{if .Ref}}<input type="hidden" name="ref" value="{{.Ref}}">{{end}}
  {{/* The layout is not a filter, but this form is a GET: without carrying it,
       submitting a filter from the stream would answer with the board. */}}
  {{if eq .Data.Layout "stream"}}<input type="hidden" name="layout" value="stream">{{end}}
  <input type="search" name="q" value="{{.Data.Filter.Query}}" placeholder="Search id / title">
  <select name="type" aria-label="Type">
    <option value="">All types</option>


@@ 355,9 387,21 @@ pre.field-body {
  </select>
  <label class="beads-ready-toggle"><input type="checkbox" name="ready" value="1"{{if .Data.Filter.Ready}} checked{{end}}> &#9889; Ready only</label>
  <button type="submit">Filter</button>
  {{if .Data.Filter.Active}}<a class="beads-filter-clear" href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads{{if .Ref}}?ref={{.Ref}}{{end}}">Clear</a>{{end}}
  {{/* Clear drops the filters and keeps the layout: the layout is how the page
       is read, not what it is filtered to. */}}
  {{if .Data.Filter.Active}}<a class="beads-filter-clear" href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads{{if .Ref}}?ref={{.Ref}}{{if eq .Data.Layout "stream"}}&amp;layout=stream{{end}}{{else}}{{if eq .Data.Layout "stream"}}?layout=stream{{end}}{{end}}">Clear</a>{{end}}
</form>

<div class="beads-layout">
  <span class="l">view:</span>
  {{if eq .Data.Layout "stream"}}
  <a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads{{withQuery .Data.Query "layout" ""}}">Board</a>
  {{else}}<span class="current" aria-current="page">Board</span>{{end}}
  <span class="sep">&middot;</span>
  {{if eq .Data.Layout "stream"}}<span class="current" aria-current="page">Stream</span>
  {{else}}<a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads{{withQuery .Data.Query "layout" "stream"}}">Stream</a>{{end}}
</div>

<div class="beads-summary">
  <div class="stat rolling"><div class="n">{{.Data.Counts.Rolling}}</div><div class="l"><span class="swatch"></span>Rolling</div></div>
  <div class="stat lined-up"><div class="n">{{.Data.Counts.LinedUp}}</div><div class="l"><span class="swatch"></span>Lined Up</div></div>


@@ 366,6 410,30 @@ pre.field-body {
  <div class="stat total"><div class="n">{{.Data.Counts.Total}}</div><div class="l">Total</div></div>
</div>

{{if eq .Data.Layout "stream"}}
{{/* ---------------- stream: the same buckets as one column ---------------- */}}
<div class="beads-stream">
  {{range .Data.Sections}}
  {{if .Collapsed}}
  <details class="beads-section {{.Slug}}">
    <summary class="stream-head">
      <span class="swatch"></span>{{.Name}}{{if .Note}}<span class="note">{{.Note}}</span>{{end}}<span class="count">{{len .Issues}}</span>
    </summary>
    {{template "beadStreamRows" (dict "Repo" $.Repo "Section" .)}}
  </details>
  {{else}}
  <section class="beads-section {{.Slug}}">
    <div class="stream-head">
      <span class="swatch"></span>{{.Name}}{{if .Note}}<span class="note">{{.Note}}</span>{{end}}<span class="count">{{len .Issues}}</span>
    </div>
    {{template "beadStreamRows" (dict "Repo" $.Repo "Section" .)}}
  </section>
  {{end}}
  {{end}}
</div>

{{else}}
{{/* ---------------- board: four lanes side by side ---------------- */}}
<div class="beads-lanes">
  {{range .Data.Lanes}}
  <div class="beads-lane {{.Slug}}">


@@ 393,6 461,33 @@ pre.field-body {
  </div>
  {{end}}
</div>
{{end}}
{{end}}{{/* layout */}}
{{end}}{{/* detail vs board */}}
</div>
{{- end}}

{{/* One stream section's cards. The row markup is the board's row markup: the
     same card, only differently arranged — so the two copies move together.
     It is copied rather than shared because folding the board's lane body into
     this partial would reflow the board's output, and the board is meant to
     render exactly what it rendered before this layout existed. */}}
{{define "beadStreamRows"}}
<div class="stream-body">
  {{range .Section.Issues}}
  <a class="bead-row" href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.ID | urlquery}}">
    <span class="r-id">{{if .Ready}}<span class="ready-dot" title="Ready to work">&#9889;</span>{{end}}{{.ID}}</span>
    <span class="r-title">{{.Title}}</span>
    <span class="r-meta">
      {{if .PriorityLabel}}<span class="blabel prio">{{.PriorityLabel}}</span>{{end}}
      {{if .Type}}<span class="blabel">{{.Type}}</span>{{end}}
      {{if .Assignee}}<span class="blabel assignee">@{{.Assignee}}</span>{{end}}
      {{range .Labels}}<span class="blabel muted">{{.}}</span>{{end}}
      {{if .BlockedBy}}<span class="blabel block">&#128679; {{.BlockedBy}}</span>{{end}}
      {{if .Blocks}}<span class="blabel muted">blocks {{.Blocks}}</span>{{end}}
    </span>
  </a>
  {{else}}
  <p class="beads-empty">Nothing in this section.</p>
  {{end}}
</div>
{{end}}