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
}
}