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 @@ // --- 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 @@ Labels: labelsByIssue[id], 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 @@ Truncated: truncated, 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,8 +1,17 @@ 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 -------------------------------------------------------------- @@ -14,13 +23,22 @@ 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 ShownOf int // when Truncated: the reported table total 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 @@ -100,6 +118,16 @@ Accent string // hex accent color for the lane header/border 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 @@ BlockedBy int // # of deps this issue has (things it waits on) 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 @@ ) // 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 @@ t.Errorf("close reason text missing; body=%s", body) } } +// --- 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 @@ m["dec"] = func(n int) int { return n - 1 } // 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 } @@ -113,6 +117,33 @@ if u.Scheme == "http" { return u.Host + ":80" } 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. 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-filter .beads-filter-clear { font-size: .82rem; color: var(--bd-muted); align-self: center; } .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; } @@ -77,6 +84,28 @@ border: 1px solid var(--bd-border); border-top: 2px solid var(--lane); } .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); } @@ -336,6 +365,9 @@ {{end}}
{{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 @@
{{.Data.Counts.PastStand}}
Past Stand
{{.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 @@
{{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}}