Coverage for little_loops / cli / issues / impact_effort.py: 0%
126 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
1"""ll-issues impact-effort: ASCII impact vs effort matrix for active issues."""
3from __future__ import annotations
5import argparse
6from typing import TYPE_CHECKING
8from little_loops.cli.output import TYPE_COLOR, colorize, terminal_width
10if TYPE_CHECKING:
11 from little_loops.config import BRConfig
12 from little_loops.issue_parser import IssueInfo
14# Effort/impact scale: 1=low, 2=medium, 3=high
15_PRIORITY_TO_EFFORT = {0: 3, 1: 3, 2: 2, 3: 2, 4: 1, 5: 1}
16_PRIORITY_TO_IMPACT = {0: 3, 1: 3, 2: 2, 3: 2, 4: 1, 5: 1}
18# Max issues to show per quadrant before truncating
19_MAX_PER_QUADRANT = 6
21_QUADRANT_HEADER_COLOR = {
22 "quick_wins": "32;1", # bold green — desirable
23 "major_projects": "33", # yellow — important, costly
24 "fill_ins": "2", # dim — low priority
25 "thankless": "38;5;208", # orange — avoid
26}
29def _infer_effort(issue: IssueInfo) -> int:
30 """Return effort level (1=low, 2=med, 3=high) from frontmatter or priority."""
31 if issue.effort is not None:
32 return max(1, min(3, issue.effort))
33 return _PRIORITY_TO_EFFORT.get(issue.priority_int, 2)
36def _infer_impact(issue: IssueInfo) -> int:
37 """Return impact level (1=low, 2=med, 3=high) from frontmatter or priority."""
38 if issue.impact is not None:
39 return max(1, min(3, issue.impact))
40 return _PRIORITY_TO_IMPACT.get(issue.priority_int, 2)
43def _issue_slug(issue: IssueInfo, col_width: int) -> str:
44 """Extract short slug from filename: description segment with hyphens→spaces, truncated."""
45 name = issue.path.stem # e.g. "P3-FEAT-505-ll-issues-cli-command"
46 parts = name.split("-", 3) # ['P3', 'FEAT', '505', 'll-issues-cli-command']
47 if len(parts) >= 4:
48 slug = parts[3].replace("-", " ")
49 else:
50 slug = issue.title
51 max_len = col_width - len(issue.issue_id) - 2
52 return slug[:max_len] if len(slug) > max_len else slug
55def _render_quadrant_lines(
56 issues: list[IssueInfo], header: str, header_color: str, col_width: int
57) -> list[str]:
58 """Render lines for a single quadrant (no borders, fixed col_width)."""
59 lines: list[str] = []
60 padded = colorize(header, header_color) + " " * (col_width - len(header))
61 lines.append(padded)
62 shown = issues[:_MAX_PER_QUADRANT]
63 for issue in shown:
64 slug = _issue_slug(issue, col_width)
65 raw = f"{issue.issue_id} {slug}"
66 padding = " " * (col_width - len(raw))
67 issue_type = issue.issue_id.split("-", 1)[0]
68 colored_id = colorize(issue.issue_id, TYPE_COLOR.get(issue_type, "0"))
69 lines.append(f"{colored_id} {slug}{padding}")
70 if len(issues) > _MAX_PER_QUADRANT:
71 extra = len(issues) - _MAX_PER_QUADRANT
72 lines.append(f" \u2026 +{extra} more".ljust(col_width))
73 if not shown:
74 lines.append("(none)".ljust(col_width))
75 return lines
78def _render_grid(
79 q_high_low: list[IssueInfo], # high impact, low effort = quick wins
80 q_high_high: list[IssueInfo], # high impact, high effort = major projects
81 q_low_low: list[IssueInfo], # low impact, low effort = fill-ins
82 q_low_high: list[IssueInfo], # low impact, high effort = thankless tasks
83) -> str:
84 """Render the 2x2 ASCII grid and return as a string."""
85 col_width = max(18, min(38, (terminal_width() - 19) // 2))
87 lines_tl = _render_quadrant_lines(
88 q_high_low, "\u2605 QUICK WINS", _QUADRANT_HEADER_COLOR["quick_wins"], col_width
89 )
90 lines_tr = _render_quadrant_lines(
91 q_high_high, "\u25b2 MAJOR PROJECTS", _QUADRANT_HEADER_COLOR["major_projects"], col_width
92 )
93 lines_bl = _render_quadrant_lines(
94 q_low_low, "\u00b7 FILL-INS", _QUADRANT_HEADER_COLOR["fill_ins"], col_width
95 )
96 lines_br = _render_quadrant_lines(
97 q_low_high, "\u2717 THANKLESS", _QUADRANT_HEADER_COLOR["thankless"], col_width
98 )
100 # Pad all quadrant line lists to the same height
101 top_height = max(len(lines_tl), len(lines_tr))
102 bot_height = max(len(lines_bl), len(lines_br))
104 def pad(lst: list[str], height: int) -> list[str]:
105 while len(lst) < height:
106 lst.append(" " * col_width)
107 return lst
109 lines_tl = pad(lines_tl, top_height)
110 lines_tr = pad(lines_tr, top_height)
111 lines_bl = pad(lines_bl, bot_height)
112 lines_br = pad(lines_br, bot_height)
114 # Box-drawing characters
115 h = "\u2500"
116 v = "\u2502"
117 tl = "\u250c"
118 tr = "\u2510"
119 bl = "\u2514"
120 br = "\u2518"
121 tm = "\u252c"
122 bm = "\u2534"
123 ml = "\u251c"
124 mr = "\u2524"
125 mid = "\u253c"
127 bar = h * (col_width + 2)
128 top_border = f"{tl}{bar}{tm}{bar}{tr}"
129 mid_border = f"{ml}{bar}{mid}{bar}{mr}"
130 bot_border = f"{bl}{bar}{bm}{bar}{br}"
132 label_width = 12 # len("High IMPACT ") == len("Low IMPACT ") == len(" " * 12)
133 grid_width = len(top_border) # 1 + (col_width+2) + 1 + (col_width+2) + 1
134 col_section = col_width + 2 # one column's width including surrounding spaces
136 out: list[str] = []
138 # Axis labels: "← EFFORT →" centered over grid; "Low"/"High" over each column
139 effort_plain = "\u2190 EFFORT \u2192"
140 effort_pad_total = grid_width - len(effort_plain)
141 effort_left = " " * (effort_pad_total // 2)
142 effort_right = " " * (effort_pad_total - effort_pad_total // 2)
143 out.append(" " * label_width + effort_left + colorize(effort_plain, "1") + effort_right)
145 low_centered = "Low".center(col_section)
146 high_plain = "High"
147 high_pad_total = col_section - len(high_plain)
148 high_left = " " * (high_pad_total // 2)
149 high_right = " " * (high_pad_total - high_pad_total // 2)
150 high_centered = high_left + colorize(high_plain, "1") + high_right
151 out.append(" " * (label_width + 1) + low_centered + " " + high_centered)
153 out.append(" " * label_width + top_border)
154 for i, (tl_line, tr_line) in enumerate(zip(lines_tl, lines_tr, strict=True)):
155 row_label = "High IMPACT " if i == 0 else " " * label_width
156 out.append(f"{row_label}{v} {tl_line} {v} {tr_line} {v}")
157 out.append(" " * label_width + mid_border)
158 for i, (bl_line, br_line) in enumerate(zip(lines_bl, lines_br, strict=True)):
159 row_label = "Low IMPACT " if i == 0 else " " * label_width
160 out.append(f"{row_label}{v} {bl_line} {v} {br_line} {v}")
161 out.append(" " * label_width + bot_border)
163 return "\n".join(out)
166def cmd_impact_effort(config: BRConfig, args: argparse.Namespace) -> int:
167 """Display impact vs effort matrix for active issues.
169 Issues are grouped into four quadrants:
170 - Quick Wins: high impact, low effort
171 - Major Projects: high impact, high effort
172 - Fill-ins: low impact, low effort
173 - Thankless Tasks: low impact, high effort
175 Effort and impact (1=low, 2=med, 3=high) are read from issue frontmatter
176 if present; otherwise inferred from priority (P0-P1=high, P2-P3=med, P4-P5=low).
178 Args:
179 config: Project configuration
180 args: Parsed arguments
182 Returns:
183 Exit code (0 = success)
184 """
185 from little_loops.issue_parser import find_issues
187 type_prefixes = {args.type} if getattr(args, "type", None) else None
188 issues = find_issues(config, type_prefixes=type_prefixes)
190 if not issues:
191 print("No active issues found.")
192 return 0
194 q_high_low: list[IssueInfo] = []
195 q_high_high: list[IssueInfo] = []
196 q_low_low: list[IssueInfo] = []
197 q_low_high: list[IssueInfo] = []
199 for issue in issues:
200 effort = _infer_effort(issue)
201 impact = _infer_impact(issue)
202 high_impact = impact >= 2
203 high_effort = effort >= 2
204 if high_impact and not high_effort:
205 q_high_low.append(issue)
206 elif high_impact and high_effort:
207 q_high_high.append(issue)
208 elif not high_impact and not high_effort:
209 q_low_low.append(issue)
210 else:
211 q_low_high.append(issue)
213 print(_render_grid(q_high_low, q_high_high, q_low_low, q_low_high))
215 total = len(issues)
216 print(f"\n {total} issue{'s' if total != 1 else ''} plotted")
217 return 0