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

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

2 

3from __future__ import annotations 

4 

5import argparse 

6from typing import TYPE_CHECKING 

7 

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

9 

10if 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 

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) 

34 

35 

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) 

41 

42 

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 

53 

54 

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 

76 

77 

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

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 

166def 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