package beads
import (
"net/url"
"strconv"
"strings"
)
// Layout names the shape the board mode is rendered in, from ?layout=. It is a
// layout of one view and not a second view: the same filtered set, the same
// buckets, the same cards, either four lanes side by side or one column.
const (
LayoutBoard = "board" // four lanes side by side; the default
LayoutStream = "stream" // one column, sections stacked in parade order
)
// --- view model --------------------------------------------------------------
// Data is the opaque .Data value handed to beads.html. Mode discriminates
// the renderings: "board" (all lanes), "detail" (one issue), or "epic" (a
// detail whose issue is an epic, which also carries its subtask rollup).
type Data struct {
Mode string // "board" | "detail" | "epic"
// board mode
Layout string // LayoutBoard | LayoutStream; "" in the detail modes
Lanes []Lane
Sections []Section // LayoutStream only: the same buckets, read top to bottom
Counts Counts
Total int // issues placed on the board (after filtering)
Filter Filter // active board filters (sticky form state)
FilterOpts FilterOptions // distinct values for the filter dropdowns
// Truncated and ShownOf are set in every mode, board and detail alike: a
// projection that read only part of a table has to say so wherever it is
// rendered, or a partial answer reads as a complete one.
//
// Truncated says some table this projection read exceeded Max and came back
// clipped. Which tables that covers is the mode's own set — the board buckets
// from issues and dependencies; the detail pane also draws on labels,
// custom_statuses, comments and events.
Truncated bool
// ShownOf is the issues table's reported total, clipped or not — what exists,
// against the rows actually read. IssuesClipped is the comparison callers
// usually want.
ShownOf int
// Clipped names every table the *board* draws from that came back clipped,
// in the order they were read, and is board mode's alone: it is empty in the
// detail modes, which read a different set of tables for one issue and say
// their one line from Truncated.
//
// It exists because Truncated could not grow to hold this. The flag is
// paired with a count line — "the first N of M issues" — so it can only mean
// the reads that decide N and M: issues and dependencies. The board also
// draws label pills and its whole filter vocabulary from labels, and every
// card's lane from custom_statuses, and a clip in either degrades the board
// without moving a single count. Folding those into the same bool would have
// made a flag that means four different things and a number that no longer
// follows from it, which is why they are reported here instead — named, with
// their own row counts, and with what the board lost by them.
Clipped []ClippedTable
// Query is the request's query as parsed, carried so the layout toggle can
// rebuild this exact URL with one key replaced (web's withQuery). The view
// envelope does not carry the query, and rebuilding it from Filter would
// silently drop everything this projection does not model — ?ref= among
// them. Set in board mode only.
Query url.Values
// detail / epic modes
Issue *Issue
DependsOn []Edge // this issue depends on … (outgoing, direct)
DependedOnBy []Edge // … is depended on by this issue (incoming, direct)
Comments []Comment // the comment thread (Comments tab)
History []Activity // comments + audit events, time-sorted (History tab)
// Transitive dependency trees (flattened, pre-order with Depth), shown only
// when they reach past the direct edges. DependsTree is the full prerequisite
// chain; DependentTree is everything this issue transitively unblocks.
DependsTree []TreeNode
DependentTree []TreeNode
// epic mode: the issue's parent-child children and their rollup.
Subtasks []Subtask
SubtaskDone int // # of subtasks in the closed category
SubtaskTotal int // len(Subtasks); the progress denominator
// Raw is the stored rows this detail was built from, in the order the tables
// were read: the issue's own row first, then the rows belonging to it in
// labels, dependencies, comments and events. Set in the detail modes only,
// and empty when the issue was not found — there is nothing stored to show.
//
// It is what makes everything above it checkable: every other field here is a
// reading of these rows, and a reading that dropped a column or humanized an
// event into the wrong sentence looks exactly like a correct one from the
// pane alone. See beads/raw.go for why the tables stop where they do.
Raw []RawTable
}
// ClippedTable is one table a projection read that exceeded Max: its name, how
// many rows were read against how many exist, and the one line saying what this
// surface lost by the rest.
//
// Effect is written at the read, because what a clipped table costs is a fact
// about the projection that read it and not about the table: the same clipped
// labels table costs the board every card's pills and the label filter's
// options, and would cost a detail pane one issue's pills. Shown and Total are
// what make the line checkable rather than a bare warning — a reader can tell
// how much is missing.
type ClippedTable struct {
Table string // the table as read, e.g. "labels"
Shown int // rows read: Max, by what a clip is
Total int // rows the store says the table holds
Effect string // what this surface loses by the rows it did not read
}
// IssuesClipped reports that the issues table itself exceeded Max, so this
// projection saw only its first Max rows. Truncated is the wider fact (any
// input table was clipped); this is the one that decides whether the issue set
// in hand is the whole tracker.
func (d *Data) IssuesClipped() bool { return d.ShownOf > Max }
// Missing reports that a detail build did not find the requested issue: the
// pane has no Issue to render. It says nothing about why — MissingBeyondCap
// does.
func (d *Data) Missing() bool { return d.Mode == "detail" && d.Issue == nil }
// MissingBeyondCap separates the two ways an issue can be missing. False with
// Missing set means the read was complete and there is no such issue. True
// means the issues table was clipped at Max and the id was not among the rows
// read — it may sit in the tail this projection never saw, and a surface that
// answers "no such issue" here is stating something it does not know.
func (d *Data) MissingBeyondCap() bool { return d.Missing() && d.IssuesClipped() }
// Filter holds the active board filters, parsed from the query string and
// echoed back into the form so selections stick across submits. Empty fields
// mean "no constraint".
type Filter struct {
Query string // substring match over id + title (case-insensitive)
Type string // exact issue_type
Priority string // exact priority ("0".."3")
Assignee string // exact assignee
Label string // issue must carry this label
Ready bool // only actionable-now issues (bd's `ready` set)
}
// Active reports whether any filter is set (drives the "Clear" link and the
// empty-board wording).
func (f Filter) Active() bool {
return f.Query != "" || f.Type != "" || f.Priority != "" || f.Assignee != "" || f.Label != "" || f.Ready
}
// matches reports whether one issue row passes every set filter.
func (f Filter) matches(id string, row rowCells, cols map[string]int, labels []string) bool {
if f.Type != "" && cell(cols, row, "issue_type") != f.Type {
return false
}
if f.Priority != "" && cell(cols, row, "priority") != f.Priority {
return false
}
if f.Assignee != "" && cell(cols, row, "assignee") != f.Assignee {
return false
}
if f.Label != "" && !containsString(labels, f.Label) {
return false
}
if f.Query != "" {
hay := strings.ToLower(id + " " + cell(cols, row, "title"))
if !strings.Contains(hay, strings.ToLower(f.Query)) {
return false
}
}
return true
}
// FilterOptions lists the distinct values present across all issues, so the
// filter dropdowns offer only real choices. Collected from the unfiltered set so
// the options don't shrink as a filter narrows the board.
type FilterOptions struct {
Types []string
Priorities []string // "0".."3"
Assignees []string
Labels []string
}
// Lane is one parade lane and the cards in it.
type Lane struct {
Name string // human label, e.g. "Rolling"
Slug string // css-safe identifier, e.g. "rolling"
Accent string // hex accent color for the lane header/border
Issues []Card
}
// Section is one section of the stream layout: a lane, plus the two things a
// section header in a single column needs that a lane header does not — whether
// it opens collapsed, and a one-line hint at the order its issues are in (the
// stream sorts each section differently, so the order is worth stating).
type Section struct {
Lane // Name, Slug, Accent, Issues — the same bucketing as the board
Collapsed bool // rendered inside <details> with no open attribute
Note string // "" or a one-line hint, e.g. "closed, newest first"
}
// Counts is the marquee: per-lane totals plus the grand total.
type Counts struct {
Rolling int
LinedUp int
Stalled int
PastStand int
Total int
}
// Card is one issue as it appears on the board.
type Card struct {
ID string
Title string
Type string
Priority string // as stored ("0".."3", ""); PriorityLabel derives the pill
Assignee string
Labels []string
BlockedBy int // # of deps this issue has (things it waits on)
Blocks int // # of deps pointing at this issue (things waiting on it)
Ready bool // actionable now: open, unblocked, not deferred/template (bd's `ready` set)
Category string // open | in_progress | closed (used by the milestones view)
// Sort keys for the stream layout, which orders Rolling by when work was
// picked up and Past Stand by when it finished. Neither is rendered on a
// card — the timestamps are on the detail pane, and a card already carries
// as much metadata as a glance holds. Stored as read ("YYYY-MM-DD HH:MM:SS"),
// so a lexical compare is a time compare; "" means unset and sorts last.
StartedAt string
ClosedAt string
}
// PriorityLabel renders the numeric priority as a P-pill label ("P0".."P3"),
// or "" when unset/unparseable so the template can omit the marker.
func (c Card) PriorityLabel() string {
if c.Priority == "" {
return ""
}
if _, err := strconv.Atoi(c.Priority); err != nil {
return ""
}
return "P" + c.Priority
}
// Edge is one dependency edge to another issue, linked in the detail pane.
type Edge struct {
IssueID string
Title string
Type string
Status string
Closed bool
}
// TreeNode is one node in a flattened transitive dependency tree. Depth is
// the indentation level (0 = a direct edge of the root issue); Type is the
// dependency type of the edge that reached this node.
type TreeNode struct {
ID string
Title string
Type string
Status string
Closed bool
Depth int
}
// Comment is one row of the comments thread.
type Comment struct {
Author string
Text string
CreatedAt string
}
// Activity is one entry in the merged history timeline: either a comment or
// an audit event from the events table. Summary is a human-readable one-liner
// ("changed status to in_progress"); Text carries the comment body or an event's
// free-text note. Kind drives the icon/label in the template.
type Activity struct {
Kind string // "comment" | "event"
Event string // events only: the event_type (created/status_changed/updated/closed/…)
Actor string
Summary string
Text string
CreatedAt string
}
// Subtask is one child of an epic — the "from" side of a parent-child
// dependency that points at the epic. Category (open/in_progress/closed) drives
// the status accent and feeds the epic's progress rollup.
type Subtask struct {
ID string
Title string
Status string
Category string
Priority string
Assignee string
Blocked bool
}
// PriorityLabel renders a subtask's numeric priority as a P-pill ("P0".."P3"),
// or "" when unset/unparseable.
func (s Subtask) PriorityLabel() string {
if s.Priority == "" {
return ""
}
if _, err := strconv.Atoi(s.Priority); err != nil {
return ""
}
return "P" + s.Priority
}
// SubtaskPct is the epic's completion percentage (0..100), for the progress bar
// width. Zero subtasks reads as 0%.
func (d *Data) SubtaskPct() int {
if d.SubtaskTotal == 0 {
return 0
}
return d.SubtaskDone * 100 / d.SubtaskTotal
}
// Issue is the full issue shown in the detail pane. The field set mirrors
// the user-facing columns bd surfaces for an issue (see `bd show`): identity and
// status, the four long-text bodies, effort/reference metadata, the full
// timestamp trail, and the close reason recorded when an issue is resolved.
type Issue struct {
ID string
Title string
Status string
Lane string
Accent string
Priority string
IssueType string
Assignee string
CreatedBy string
Owner string
EstimatedMinutes string
ExternalRef string
SpecID string
Description string
Design string
AcceptanceCriteria string
Notes string
CreatedAt string
StartedAt string
UpdatedAt string
ClosedAt string
CloseReason string
Labels []string
}