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