coverage~bigbes/sr-ht-dolt3523280cbeads/stream.go

Coverage
76.4% 42/55 statements
Δ
+0.0
Blob
656203c
1 package beads
2
3 import (
4 "sort"
5 "strings"
6 )
7
8 // --- the stream layout -------------------------------------------------------
9 //
10 // The board is four lanes side by side and sorts every one of them the same way,
11 // which is right for comparing lanes. A single column is read top to bottom, and
12 // then each section answers a different question, so each gets its own order.
13
14 // parseLayout reads ?layout=. Anything other than "stream" — absent, misspelled,
15 // or a layout that no longer exists — is the board: a stale link renders the
16 // default page rather than an error.
17 51 func parseLayout(v string) string {
18 51 if strings.EqualFold(strings.TrimSpace(v), LayoutStream) {
19 14 return LayoutStream
20 14 }
21 37 return LayoutBoard
22 }
23
24 // streamSections turns the finished lanes into the stream's sections, in parade
25 // order. Bucketing is not repeated: each section is the lane it names, so one
26 // issue lands in exactly one section and the section counts are the marquee's
27 // counts by construction. Only the order inside a section changes, on a copy —
28 // the lanes keep the board order the board renders.
29 //
30 // created is the id → created_at map the board sort already built; Lined Up is
31 // the one section that orders by it.
32 14 func streamSections(lanes []Lane, created map[string]string) []Section {
33 14 sections := make([]Section, 0, len(lanes))
34 56 for _, lane := range lanes {
35 56 sec := Section{Lane: lane}
36 56 sec.Issues = append([]Card(nil), lane.Issues...)
37 56 // The lane set is built in Build and this switch covers it; a lane outside
38 56 // it would keep the board's order and carry no note, which is the honest
39 56 // rendering of "this section has no reading order of its own".
40 56 switch lane.Slug {
41 14 case "rolling":
42 14 sortRolling(sec.Issues)
43 14 sec.Note = "most recently started first"
44 14 case "lined-up":
45 14 sortLinedUp(sec.Issues, created)
46 14 sec.Note = "ready first, then priority"
47 14 case "stalled":
48 14 sortStalled(sec.Issues)
49 14 sec.Note = "fewest blockers first"
50 14 case "past-stand":
51 14 sortPastStand(sec.Issues)
52 14 sec.Note = "closed, newest first"
53 14 // The largest section and the least actionable one: it opens closed so
54 14 // the three sections above it stay reachable without scrolling past a
55 14 // log. <details> does that with no JavaScript.
56 14 sec.Collapsed = true
57 }
58 56 sections = append(sections, sec)
59 }
60 14 return sections
61 }
62
63 // sortRolling orders in-progress work by when it was picked up, most recent
64 // first — what was started last is what is actually being worked on. Ties fall
65 // back to priority, then id.
66 14 func sortRolling(cards []Card) {
67 22 sort.SliceStable(cards, func(i, j int) bool {
68 22 if c := cmpTimeDesc(cards[i].StartedAt, cards[j].StartedAt); c != 0 {
69 22 return c < 0
70 22 }
71 0 if pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority); pi != pj {
72 0 return pi < pj
73 0 }
74 0 return cards[i].ID < cards[j].ID
75 })
76 }
77
78 // sortLinedUp leads with the ready set: this is the "what can I take" section,
79 // and an issue that is actionable now belongs above one that is merely open.
80 // Then priority, then oldest first, then id.
81 14 func sortLinedUp(cards []Card, created map[string]string) {
82 85 sort.SliceStable(cards, func(i, j int) bool {
83 85 if ri, rj := cards[i].Ready, cards[j].Ready; ri != rj {
84 40 return ri
85 40 }
86 45 if pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority); pi != pj {
87 27 return pi < pj
88 27 }
89 18 if c := cmpTimeAsc(created[cards[i].ID], created[cards[j].ID]); c != 0 {
90 18 return c < 0
91 18 }
92 0 return cards[i].ID < cards[j].ID
93 })
94 }
95
96 // sortStalled orders by how far each issue is from moving: one blocker away is
97 // nearer than five. Then priority, then id.
98 14 func sortStalled(cards []Card) {
99 24 sort.SliceStable(cards, func(i, j int) bool {
100 24 if bi, bj := cards[i].BlockedBy, cards[j].BlockedBy; bi != bj {
101 24 return bi < bj
102 24 }
103 0 if pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority); pi != pj {
104 0 return pi < pj
105 0 }
106 0 return cards[i].ID < cards[j].ID
107 })
108 }
109
110 // sortPastStand orders the log by when work finished, most recent on top. There
111 // is no priority tiebreak: nothing here is prioritised any more.
112 14 func sortPastStand(cards []Card) {
113 25 sort.SliceStable(cards, func(i, j int) bool {
114 25 if c := cmpTimeDesc(cards[i].ClosedAt, cards[j].ClosedAt); c != 0 {
115 25 return c < 0
116 25 }
117 0 return cards[i].ID < cards[j].ID
118 })
119 }
120
121 // cmpTimeAsc compares two stored timestamps oldest first, with the unset value
122 // LAST rather than first. beads timestamps share the "YYYY-MM-DD HH:MM:SS"
123 // shape, so a lexical compare is a time compare — but "" is lexically smaller
124 // than every date, and an issue whose start or close was never recorded is not
125 // the oldest issue in the section. It is the one nothing is known about, and it
126 // belongs at the bottom.
127 18 func cmpTimeAsc(a, b string) int {
128 18 switch {
129 0 case a == b:
130 0 return 0
131 0 case a == "":
132 0 return 1
133 9 case b == "":
134 9 return -1
135 0 case a < b:
136 0 return -1
137 9 default:
138 9 return 1
139 }
140 }
141
142 // cmpTimeDesc is cmpTimeAsc reversed for the present values, keeping the unset
143 // one last (a plain negation would float it to the top).
144 47 func cmpTimeDesc(a, b string) int {
145 47 switch {
146 0 case a == b:
147 0 return 0
148 8 case a == "":
149 8 return 1
150 24 case b == "":
151 24 return -1
152 15 case a > b:
153 15 return -1
154 0 default:
155 0 return 1
156 }
157 }