Coverage for little_loops/cli/issues/impact_effort.py: 0%

1"""ll-issues impact-effort: ASCII impact vs effort matrix for active issues."""

2

3 from __future__ import annotations

4

5 import argparse

6 from typing import TYPE_CHECKING

7

8 from little_loops.cli.output import TYPE_COLOR, colorize, terminal_width

9

10 if TYPE_CHECKING:

11 from little_loops.config import BRConfig

12 from little_loops.issue_parser import IssueInfo

13

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}

17

18# Max issues to show per quadrant before truncating

19 _MAX_PER_QUADRANT = 6

20

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}

27

28

29 def _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)

34

35

36 def _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)

41

42

43 def _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

53

54

55 def _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

76

77

78 def _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))

86

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 )

99

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

103

104 def pad(lst: list[str], height: int) -> list[str]:

105 while len(lst) < height:

106 lst.append(" " * col_width)

107 return lst

108

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)

113

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"

126

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

131

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

135

136 out: list[str] = []

137

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)

144

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)

152

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)

162

163 return "\n".join(out)

164

165

166 def cmd_impact_effort(config: BRConfig, args: argparse.Namespace) -> int:

167 """Display impact vs effort matrix for active issues.

168

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

174

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).

177

178 Args:

179 config: Project configuration

180 args: Parsed arguments

181

182 Returns:

183 Exit code (0 = success)

184 """

185 from little_loops.issue_parser import find_issues

186

187 type_prefixes = {args.type} if getattr(args, "type", None) else None

188 issues = find_issues(config, type_prefixes=type_prefixes)

189

190 if not issues:

191 print("No active issues found.")

192 return 0

193

194 q_high_low: list[IssueInfo] = []

195 q_high_high: list[IssueInfo] = []

196 q_low_low: list[IssueInfo] = []

197 q_low_high: list[IssueInfo] = []

198

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)

212

213 print(_render_grid(q_high_low, q_high_high, q_low_low, q_low_high))

214

215 total = len(issues)

216 print(f"\n {total} issue{'s' if total != 1 else ''} plotted")

217 return 0