coverage~bigbes/sr-ht-doltede3b0bbbeads/model.go

Coverage
63.3% 19/30 statements
Δ
Blob
430c86e
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 }