| 1 |
|
package beads |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"net/url" |
| 5 |
|
"strconv" |
| 6 |
|
"strings" |
| 7 |
|
) |
| 8 |
|
|
| 9 |
|
// Layout names the shape the board mode is rendered in, from ?layout=. It is a |
| 10 |
|
// layout of one view and not a second view: the same filtered set, the same |
| 11 |
|
// buckets, the same cards, either four lanes side by side or one column. |
| 12 |
|
const ( |
| 13 |
|
LayoutBoard = "board" // four lanes side by side; the default |
| 14 |
|
LayoutStream = "stream" // one column, sections stacked in parade order |
| 15 |
|
) |
| 16 |
|
|
| 17 |
|
// --- view model -------------------------------------------------------------- |
| 18 |
|
|
| 19 |
|
// Data is the opaque .Data value handed to beads.html. Mode discriminates |
| 20 |
|
// the renderings: "board" (all lanes), "detail" (one issue), or "epic" (a |
| 21 |
|
// detail whose issue is an epic, which also carries its subtask rollup). |
| 22 |
|
type Data struct { |
| 23 |
|
Mode string // "board" | "detail" | "epic" |
| 24 |
|
|
| 25 |
|
// board mode |
| 26 |
|
Layout string // LayoutBoard | LayoutStream; "" in the detail modes |
| 27 |
|
Lanes []Lane |
| 28 |
|
Sections []Section // LayoutStream only: the same buckets, read top to bottom |
| 29 |
|
Counts Counts |
| 30 |
|
Total int // issues placed on the board (after filtering) |
| 31 |
|
Filter Filter // active board filters (sticky form state) |
| 32 |
|
FilterOpts FilterOptions // distinct values for the filter dropdowns |
| 33 |
|
|
| 34 |
|
// Truncated and ShownOf are set in every mode, board and detail alike: a |
| 35 |
|
// projection that read only part of a table has to say so wherever it is |
| 36 |
|
// rendered, or a partial answer reads as a complete one. |
| 37 |
|
// |
| 38 |
|
// Truncated says some table this projection read exceeded Max and came back |
| 39 |
|
// clipped. Which tables that covers is the mode's own set — the board buckets |
| 40 |
|
// from issues and dependencies; the detail pane also draws on labels, |
| 41 |
|
// custom_statuses, comments and events. |
| 42 |
|
Truncated bool |
| 43 |
|
// ShownOf is the issues table's reported total, clipped or not — what exists, |
| 44 |
|
// against the rows actually read. IssuesClipped is the comparison callers |
| 45 |
|
// usually want. |
| 46 |
|
ShownOf int |
| 47 |
|
|
| 48 |
|
// Clipped names every table the *board* draws from that came back clipped, |
| 49 |
|
// in the order they were read, and is board mode's alone: it is empty in the |
| 50 |
|
// detail modes, which read a different set of tables for one issue and say |
| 51 |
|
// their one line from Truncated. |
| 52 |
|
// |
| 53 |
|
// It exists because Truncated could not grow to hold this. The flag is |
| 54 |
|
// paired with a count line — "the first N of M issues" — so it can only mean |
| 55 |
|
// the reads that decide N and M: issues and dependencies. The board also |
| 56 |
|
// draws label pills and its whole filter vocabulary from labels, and every |
| 57 |
|
// card's lane from custom_statuses, and a clip in either degrades the board |
| 58 |
|
// without moving a single count. Folding those into the same bool would have |
| 59 |
|
// made a flag that means four different things and a number that no longer |
| 60 |
|
// follows from it, which is why they are reported here instead — named, with |
| 61 |
|
// their own row counts, and with what the board lost by them. |
| 62 |
|
Clipped []ClippedTable |
| 63 |
|
|
| 64 |
|
// Query is the request's query as parsed, carried so the layout toggle can |
| 65 |
|
// rebuild this exact URL with one key replaced (web's withQuery). The view |
| 66 |
|
// envelope does not carry the query, and rebuilding it from Filter would |
| 67 |
|
// silently drop everything this projection does not model — ?ref= among |
| 68 |
|
// them. Set in board mode only. |
| 69 |
|
Query url.Values |
| 70 |
|
|
| 71 |
|
// detail / epic modes |
| 72 |
|
Issue *Issue |
| 73 |
|
DependsOn []Edge // this issue depends on … (outgoing, direct) |
| 74 |
|
DependedOnBy []Edge // … is depended on by this issue (incoming, direct) |
| 75 |
|
Comments []Comment // the comment thread (Comments tab) |
| 76 |
|
History []Activity // comments + audit events, time-sorted (History tab) |
| 77 |
|
|
| 78 |
|
// Transitive dependency trees (flattened, pre-order with Depth), shown only |
| 79 |
|
// when they reach past the direct edges. DependsTree is the full prerequisite |
| 80 |
|
// chain; DependentTree is everything this issue transitively unblocks. |
| 81 |
|
DependsTree []TreeNode |
| 82 |
|
DependentTree []TreeNode |
| 83 |
|
|
| 84 |
|
// epic mode: the issue's parent-child children and their rollup. |
| 85 |
|
Subtasks []Subtask |
| 86 |
|
SubtaskDone int // # of subtasks in the closed category |
| 87 |
|
SubtaskTotal int // len(Subtasks); the progress denominator |
| 88 |
|
|
| 89 |
|
// Raw is the stored rows this detail was built from, in the order the tables |
| 90 |
|
// were read: the issue's own row first, then the rows belonging to it in |
| 91 |
|
// labels, dependencies, comments and events. Set in the detail modes only, |
| 92 |
|
// and empty when the issue was not found — there is nothing stored to show. |
| 93 |
|
// |
| 94 |
|
// It is what makes everything above it checkable: every other field here is a |
| 95 |
|
// reading of these rows, and a reading that dropped a column or humanized an |
| 96 |
|
// event into the wrong sentence looks exactly like a correct one from the |
| 97 |
|
// pane alone. See beads/raw.go for why the tables stop where they do. |
| 98 |
|
Raw []RawTable |
| 99 |
|
} |
| 100 |
|
|
| 101 |
|
// ClippedTable is one table a projection read that exceeded Max: its name, how |
| 102 |
|
// many rows were read against how many exist, and the one line saying what this |
| 103 |
|
// surface lost by the rest. |
| 104 |
|
// |
| 105 |
|
// Effect is written at the read, because what a clipped table costs is a fact |
| 106 |
|
// about the projection that read it and not about the table: the same clipped |
| 107 |
|
// labels table costs the board every card's pills and the label filter's |
| 108 |
|
// options, and would cost a detail pane one issue's pills. Shown and Total are |
| 109 |
|
// what make the line checkable rather than a bare warning — a reader can tell |
| 110 |
|
// how much is missing. |
| 111 |
|
type ClippedTable struct { |
| 112 |
|
Table string // the table as read, e.g. "labels" |
| 113 |
|
Shown int // rows read: Max, by what a clip is |
| 114 |
|
Total int // rows the store says the table holds |
| 115 |
|
Effect string // what this surface loses by the rows it did not read |
| 116 |
|
} |
| 117 |
|
|
| 118 |
|
// IssuesClipped reports that the issues table itself exceeded Max, so this |
| 119 |
|
// projection saw only its first Max rows. Truncated is the wider fact (any |
| 120 |
|
// input table was clipped); this is the one that decides whether the issue set |
| 121 |
|
// in hand is the whole tracker. |
| 122 |
10 |
func (d *Data) IssuesClipped() bool { return d.ShownOf > Max } |
| 123 |
|
|
| 124 |
|
// Missing reports that a detail build did not find the requested issue: the |
| 125 |
|
// pane has no Issue to render. It says nothing about why — MissingBeyondCap |
| 126 |
|
// does. |
| 127 |
8 |
func (d *Data) Missing() bool { return d.Mode == "detail" && d.Issue == nil } |
| 128 |
|
|
| 129 |
|
// MissingBeyondCap separates the two ways an issue can be missing. False with |
| 130 |
|
// Missing set means the read was complete and there is no such issue. True |
| 131 |
|
// means the issues table was clipped at Max and the id was not among the rows |
| 132 |
|
// read — it may sit in the tail this projection never saw, and a surface that |
| 133 |
|
// answers "no such issue" here is stating something it does not know. |
| 134 |
3 |
func (d *Data) MissingBeyondCap() bool { return d.Missing() && d.IssuesClipped() } |
| 135 |
|
|
| 136 |
|
// Filter holds the active board filters, parsed from the query string and |
| 137 |
|
// echoed back into the form so selections stick across submits. Empty fields |
| 138 |
|
// mean "no constraint". |
| 139 |
|
type Filter struct { |
| 140 |
|
Query string // substring match over id + title (case-insensitive) |
| 141 |
|
Type string // exact issue_type |
| 142 |
|
Priority string // exact priority ("0".."3") |
| 143 |
|
Assignee string // exact assignee |
| 144 |
|
Label string // issue must carry this label |
| 145 |
|
Ready bool // only actionable-now issues (bd's `ready` set) |
| 146 |
|
} |
| 147 |
|
|
| 148 |
|
// Active reports whether any filter is set (drives the "Clear" link and the |
| 149 |
|
// empty-board wording). |
| 150 |
8 |
func (f Filter) Active() bool { |
| 151 |
8 |
return f.Query != "" || f.Type != "" || f.Priority != "" || f.Assignee != "" || f.Label != "" || f.Ready |
| 152 |
8 |
} |
| 153 |
|
|
| 154 |
|
// matches reports whether one issue row passes every set filter. |
| 155 |
6470 |
func (f Filter) matches(id string, row rowCells, cols map[string]int, labels []string) bool { |
| 156 |
6470 |
if f.Type != "" && cell(cols, row, "issue_type") != f.Type { |
| 157 |
64 |
return false |
| 158 |
64 |
} |
| 159 |
6406 |
if f.Priority != "" && cell(cols, row, "priority") != f.Priority { |
| 160 |
20 |
return false |
| 161 |
20 |
} |
| 162 |
6386 |
if f.Assignee != "" && cell(cols, row, "assignee") != f.Assignee { |
| 163 |
29 |
return false |
| 164 |
29 |
} |
| 165 |
6357 |
if f.Label != "" && !containsString(labels, f.Label) { |
| 166 |
3 |
return false |
| 167 |
3 |
} |
| 168 |
6354 |
if f.Query != "" { |
| 169 |
38 |
hay := strings.ToLower(id + " " + cell(cols, row, "title")) |
| 170 |
38 |
if !strings.Contains(hay, strings.ToLower(f.Query)) { |
| 171 |
30 |
return false |
| 172 |
30 |
} |
| 173 |
|
} |
| 174 |
6324 |
return true |
| 175 |
|
} |
| 176 |
|
|
| 177 |
|
// FilterOptions lists the distinct values present across all issues, so the |
| 178 |
|
// filter dropdowns offer only real choices. Collected from the unfiltered set so |
| 179 |
|
// the options don't shrink as a filter narrows the board. |
| 180 |
|
type FilterOptions struct { |
| 181 |
|
Types []string |
| 182 |
|
Priorities []string // "0".."3" |
| 183 |
|
Assignees []string |
| 184 |
|
Labels []string |
| 185 |
|
} |
| 186 |
|
|
| 187 |
|
// Lane is one parade lane and the cards in it. |
| 188 |
|
type Lane struct { |
| 189 |
|
Name string // human label, e.g. "Rolling" |
| 190 |
|
Slug string // css-safe identifier, e.g. "rolling" |
| 191 |
|
Accent string // hex accent color for the lane header/border |
| 192 |
|
Issues []Card |
| 193 |
|
} |
| 194 |
|
|
| 195 |
|
// Section is one section of the stream layout: a lane, plus the two things a |
| 196 |
|
// section header in a single column needs that a lane header does not — whether |
| 197 |
|
// it opens collapsed, and a one-line hint at the order its issues are in (the |
| 198 |
|
// stream sorts each section differently, so the order is worth stating). |
| 199 |
|
type Section struct { |
| 200 |
|
Lane // Name, Slug, Accent, Issues — the same bucketing as the board |
| 201 |
|
Collapsed bool // rendered inside <details> with no open attribute |
| 202 |
|
Note string // "" or a one-line hint, e.g. "closed, newest first" |
| 203 |
|
} |
| 204 |
|
|
| 205 |
|
// Counts is the marquee: per-lane totals plus the grand total. |
| 206 |
|
type Counts struct { |
| 207 |
|
Rolling int |
| 208 |
|
LinedUp int |
| 209 |
|
Stalled int |
| 210 |
|
PastStand int |
| 211 |
|
Total int |
| 212 |
|
} |
| 213 |
|
|
| 214 |
|
// Card is one issue as it appears on the board. |
| 215 |
|
type Card struct { |
| 216 |
|
ID string |
| 217 |
|
Title string |
| 218 |
|
Type string |
| 219 |
|
Priority string // as stored ("0".."3", ""); PriorityLabel derives the pill |
| 220 |
|
Assignee string |
| 221 |
|
Labels []string |
| 222 |
|
BlockedBy int // # of deps this issue has (things it waits on) |
| 223 |
|
Blocks int // # of deps pointing at this issue (things waiting on it) |
| 224 |
|
Ready bool // actionable now: open, unblocked, not deferred/template (bd's `ready` set) |
| 225 |
|
Category string // open | in_progress | closed (used by the milestones view) |
| 226 |
|
|
| 227 |
|
// Sort keys for the stream layout, which orders Rolling by when work was |
| 228 |
|
// picked up and Past Stand by when it finished. Neither is rendered on a |
| 229 |
|
// card — the timestamps are on the detail pane, and a card already carries |
| 230 |
|
// as much metadata as a glance holds. Stored as read ("YYYY-MM-DD HH:MM:SS"), |
| 231 |
|
// so a lexical compare is a time compare; "" means unset and sorts last. |
| 232 |
|
StartedAt string |
| 233 |
|
ClosedAt string |
| 234 |
|
} |
| 235 |
|
|
| 236 |
|
// PriorityLabel renders the numeric priority as a P-pill label ("P0".."P3"), |
| 237 |
|
// or "" when unset/unparseable so the template can omit the marker. |
| 238 |
0 |
func (c Card) PriorityLabel() string { |
| 239 |
0 |
if c.Priority == "" { |
| 240 |
0 |
return "" |
| 241 |
0 |
} |
| 242 |
0 |
if _, err := strconv.Atoi(c.Priority); err != nil { |
| 243 |
0 |
return "" |
| 244 |
0 |
} |
| 245 |
0 |
return "P" + c.Priority |
| 246 |
|
} |
| 247 |
|
|
| 248 |
|
// Edge is one dependency edge to another issue, linked in the detail pane. |
| 249 |
|
type Edge struct { |
| 250 |
|
IssueID string |
| 251 |
|
Title string |
| 252 |
|
Type string |
| 253 |
|
Status string |
| 254 |
|
Closed bool |
| 255 |
|
} |
| 256 |
|
|
| 257 |
|
// TreeNode is one node in a flattened transitive dependency tree. Depth is |
| 258 |
|
// the indentation level (0 = a direct edge of the root issue); Type is the |
| 259 |
|
// dependency type of the edge that reached this node. |
| 260 |
|
type TreeNode struct { |
| 261 |
|
ID string |
| 262 |
|
Title string |
| 263 |
|
Type string |
| 264 |
|
Status string |
| 265 |
|
Closed bool |
| 266 |
|
Depth int |
| 267 |
|
} |
| 268 |
|
|
| 269 |
|
// Comment is one row of the comments thread. |
| 270 |
|
type Comment struct { |
| 271 |
|
Author string |
| 272 |
|
Text string |
| 273 |
|
CreatedAt string |
| 274 |
|
} |
| 275 |
|
|
| 276 |
|
// Activity is one entry in the merged history timeline: either a comment or |
| 277 |
|
// an audit event from the events table. Summary is a human-readable one-liner |
| 278 |
|
// ("changed status to in_progress"); Text carries the comment body or an event's |
| 279 |
|
// free-text note. Kind drives the icon/label in the template. |
| 280 |
|
type Activity struct { |
| 281 |
|
Kind string // "comment" | "event" |
| 282 |
|
Event string // events only: the event_type (created/status_changed/updated/closed/…) |
| 283 |
|
Actor string |
| 284 |
|
Summary string |
| 285 |
|
Text string |
| 286 |
|
CreatedAt string |
| 287 |
|
} |
| 288 |
|
|
| 289 |
|
// Subtask is one child of an epic — the "from" side of a parent-child |
| 290 |
|
// dependency that points at the epic. Category (open/in_progress/closed) drives |
| 291 |
|
// the status accent and feeds the epic's progress rollup. |
| 292 |
|
type Subtask struct { |
| 293 |
|
ID string |
| 294 |
|
Title string |
| 295 |
|
Status string |
| 296 |
|
Category string |
| 297 |
|
Priority string |
| 298 |
|
Assignee string |
| 299 |
|
Blocked bool |
| 300 |
|
} |
| 301 |
|
|
| 302 |
|
// PriorityLabel renders a subtask's numeric priority as a P-pill ("P0".."P3"), |
| 303 |
|
// or "" when unset/unparseable. |
| 304 |
0 |
func (s Subtask) PriorityLabel() string { |
| 305 |
0 |
if s.Priority == "" { |
| 306 |
0 |
return "" |
| 307 |
0 |
} |
| 308 |
0 |
if _, err := strconv.Atoi(s.Priority); err != nil { |
| 309 |
0 |
return "" |
| 310 |
0 |
} |
| 311 |
0 |
return "P" + s.Priority |
| 312 |
|
} |
| 313 |
|
|
| 314 |
|
// SubtaskPct is the epic's completion percentage (0..100), for the progress bar |
| 315 |
|
// width. Zero subtasks reads as 0%. |
| 316 |
1 |
func (d *Data) SubtaskPct() int { |
| 317 |
1 |
if d.SubtaskTotal == 0 { |
| 318 |
0 |
return 0 |
| 319 |
0 |
} |
| 320 |
1 |
return d.SubtaskDone * 100 / d.SubtaskTotal |
| 321 |
|
} |
| 322 |
|
|
| 323 |
|
// Issue is the full issue shown in the detail pane. The field set mirrors |
| 324 |
|
// the user-facing columns bd surfaces for an issue (see `bd show`): identity and |
| 325 |
|
// status, the four long-text bodies, effort/reference metadata, the full |
| 326 |
|
// timestamp trail, and the close reason recorded when an issue is resolved. |
| 327 |
|
type Issue struct { |
| 328 |
|
ID string |
| 329 |
|
Title string |
| 330 |
|
Status string |
| 331 |
|
Lane string |
| 332 |
|
Accent string |
| 333 |
|
Priority string |
| 334 |
|
IssueType string |
| 335 |
|
Assignee string |
| 336 |
|
CreatedBy string |
| 337 |
|
Owner string |
| 338 |
|
EstimatedMinutes string |
| 339 |
|
ExternalRef string |
| 340 |
|
SpecID string |
| 341 |
|
Description string |
| 342 |
|
Design string |
| 343 |
|
AcceptanceCriteria string |
| 344 |
|
Notes string |
| 345 |
|
CreatedAt string |
| 346 |
|
StartedAt string |
| 347 |
|
UpdatedAt string |
| 348 |
|
ClosedAt string |
| 349 |
|
CloseReason string |
| 350 |
|
Labels []string |
| 351 |
|
} |