From fe616c89c202c97268fe09ae2311c8cf30ca9059 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 13 Aug 2026 08:16:02 +0300 Subject: [PATCH] beads: add a one-column stream layout beside the board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ?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
— 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. --- beads/build.go | 14 +- beads/model.go | 36 +++++ beads/stream.go | 157 +++++++++++++++++++++ beads/stream_test.go | 295 +++++++++++++++++++++++++++++++++++++++ web/beads.go | 7 +- web/beads_test.go | 134 ++++++++++++++++++ web/templates.go | 31 ++++ web/templates/beads.html | 99 ++++++++++++- 8 files changed, 768 insertions(+), 5 deletions(-) create mode 100644 beads/stream.go create mode 100644 beads/stream_test.go diff --git a/beads/build.go b/beads/build.go index cd060e0ccded9fb68232a2902a71f8a507bbde3d..d4778b76eb4c8a4ea2e653a1ffbfdfdc78f0ce61 100644 --- a/beads/build.go +++ b/beads/build.go @@ -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 } diff --git a/beads/model.go b/beads/model.go index 6e68e0f9f832bbf89d8481590d6e204de795c64c..4e819b1d46351ba79d76daae06bfc3340fb2df42 100644 --- a/beads/model.go +++ b/beads/model.go @@ -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
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"), diff --git a/beads/stream.go b/beads/stream.go new file mode 100644 index 0000000000000000000000000000000000000000..656203c0bf6fd90b1349eafc9d329358135ae8da --- /dev/null +++ b/beads/stream.go @@ -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.
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 + } +} diff --git a/beads/stream_test.go b/beads/stream_test.go new file mode 100644 index 0000000000000000000000000000000000000000..598231148a1f085eac6b5018aa0b2e526a6d5b7a --- /dev/null +++ b/beads/stream_test.go @@ -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) +} diff --git a/web/beads.go b/web/beads.go index 3f07cde0413f09b73d4e974cce116d732a1908f8..1fee93c07628eb3597a1ed1da4027d7bc3315532 100644 --- a/web/beads.go +++ b/web/beads.go @@ -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=. All data is read through the -// BrowseSession surface (Rows/Tables) — there is no SQL engine behind it. +// per-issue detail pane reachable via ?issue=. ?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 diff --git a/web/beads_test.go b/web/beads_test.go index 766fa6277ab7e415774043a8a3fa2b587f97771e..3a788cf869153bf74d792230541e4e6fcc736a63 100644 --- a/web/beads_test.go +++ b/web/beads_test.go @@ -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
with no open attribute. + if !strings.Contains(body, `
`) { + t.Errorf("Past Stand should render as a collapsed
; body=%s", body) + } + if strings.Contains(body, "
; 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, ``) { + t.Errorf("stream form missing the layout carrier; body=%s", body) + } + if !strings.Contains(body, `Stream`) { + t.Errorf("toggle should mark Stream as current; body=%s", body) + } + if !strings.Contains(body, `href="/~alice/db/view/beads">Board`) { + t.Errorf("toggle should link back to an unfiltered board; body=%s", body) + } + // No JavaScript is added by this layout. + if strings.Contains(body, "Clear`) { + 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}) diff --git a/web/templates.go b/web/templates.go index ece9dd9622d3ed8b85658b6acb0bdaf663b219b8..d72428de5b095d844481f3486484d1f05e75f1a8 100644 --- a/web/templates.go +++ b/web/templates.go @@ -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 diff --git a/web/templates/beads.html b/web/templates/beads.html index ac6cd0754a32090a6ffb9874cffe5bf39fef5ccf..ae62eae66ff756f2aee9b56edec9799f08294ce0 100644 --- a/web/templates/beads.html +++ b/web/templates/beads.html @@ -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 {
{{if .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"}}{{end}} - {{if .Data.Filter.Active}}Clear{{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}}Clear{{end}}
+
+ view: + {{if eq .Data.Layout "stream"}} + Board + {{else}}Board{{end}} + · + {{if eq .Data.Layout "stream"}}Stream + {{else}}Stream{{end}} +
+
{{.Data.Counts.Rolling}}
Rolling
{{.Data.Counts.LinedUp}}
Lined Up
@@ -366,6 +410,30 @@ pre.field-body {
{{.Data.Counts.Total}}
Total
+{{if eq .Data.Layout "stream"}} +{{/* ---------------- stream: the same buckets as one column ---------------- */}} +
+ {{range .Data.Sections}} + {{if .Collapsed}} +
+ + {{.Name}}{{if .Note}}{{.Note}}{{end}}{{len .Issues}} + + {{template "beadStreamRows" (dict "Repo" $.Repo "Section" .)}} +
+ {{else}} +
+
+ {{.Name}}{{if .Note}}{{.Note}}{{end}}{{len .Issues}} +
+ {{template "beadStreamRows" (dict "Repo" $.Repo "Section" .)}} +
+ {{end}} + {{end}} +
+ +{{else}} +{{/* ---------------- board: four lanes side by side ---------------- */}}
{{range .Data.Lanes}}
@@ -393,6 +461,33 @@ pre.field-body {
{{end}}
-{{end}} +{{end}}{{/* layout */}} +{{end}}{{/* detail vs board */}} {{- 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"}} + +{{end}}