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