Coverage for src/secchi/ui/widgets/overview.py: 73%

330 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-04 22:15 +0000

1"""Overview tab: a compact 3 x 2 package-intelligence dashboard.""" 

2 

3from __future__ import annotations 

4 

5from collections import defaultdict 

6from datetime import datetime, timezone 

7from itertools import pairwise 

8 

9from rich.markup import escape 

10from textual import on 

11from textual.app import ComposeResult 

12from textual.containers import Grid, Horizontal, Vertical 

13from textual.events import Resize 

14from textual.widget import Widget 

15from textual.widgets import Button, Static 

16 

17from secchi.models import ( 

18 DerivedPackageData, 

19 DownloadTrendPoint, 

20 MetricTimelinePoint, 

21 PackageInfo, 

22 Registry, 

23) 

24from secchi.ui import palette 

25from secchi.ui.widgets.bar import render_bar 

26from secchi.ui.widgets.panel import Panel 

27from secchi.utils import format_pct_delta, shorten_number 

28 

29_RANGES: tuple[tuple[str, int], ...] = (("30d", 30), ("90d", 90), ("1y", 365)) 

30 

31 

32def _downloads_source(registry: Registry) -> str: 

33 return { 

34 Registry.CRATES: "Source: crates.io", 

35 Registry.PYPI: "Source: PyPI (via pypistats)", 

36 Registry.NPM: "Source: npm registry", 

37 }[registry] 

38 

39 

40def _source_registries(info: PackageInfo) -> list[Registry]: 

41 return info.source_registries or [info.registry] 

42 

43 

44class OverviewTab(Vertical): 

45 """Composes the Overview dashboard into a two-row, three-column grid.""" 

46 

47 def __init__(self, info: PackageInfo, derived: DerivedPackageData) -> None: 

48 super().__init__(id="overview-tab") 

49 self._info = info 

50 self._derived = derived 

51 self._range_days = 30 

52 

53 def compose(self) -> ComposeResult: 

54 with Horizontal(id="overview-range"): 

55 yield Static("Range", classes="overview-range-label") 

56 for label, days in _RANGES: 

57 classes = "overview-range-button" 

58 if days == self._range_days: 

59 classes += " overview-range-button--active" 

60 yield Button(label, id=f"overview-range-{days}", classes=classes) 

61 

62 with Grid(id="overview-grid"): 

63 yield AdoptionTrendPanel(self._info, self._derived, self._range_days) 

64 yield HealthScorePanel(self._info, self._derived) 

65 yield EcosystemDistributionPanel(self._info, self._derived) 

66 yield ReverseDependenciesPanel(self._info, self._derived) 

67 yield HealthTimelinePanel(self._info, self._derived) 

68 yield VersionAdoptionPanel(self._info, self._derived) 

69 

70 @on(Button.Pressed, ".overview-range-button") 

71 def _on_range_pressed(self, event: Button.Pressed) -> None: 

72 button_id = event.button.id or "" 

73 prefix = "overview-range-" 

74 if not button_id.startswith(prefix): 

75 return 

76 try: 

77 self._range_days = int(button_id.removeprefix(prefix)) 

78 except ValueError: 

79 return 

80 event.stop() 

81 self.refresh(recompose=True) 

82 

83 

84class AdoptionTrendPanel(Panel): 

85 def __init__( 

86 self, 

87 info: PackageInfo, 

88 derived: DerivedPackageData, 

89 range_days: int, 

90 ) -> None: 

91 self._info = info 

92 self._range_days = range_days 

93 registries = _source_registries(info) 

94 caption = ( 

95 "Source: combined registry downloads" 

96 if len(registries) > 1 

97 else _downloads_source(info.registry) 

98 ) 

99 super().__init__("ADOPTION TREND", caption=caption) 

100 

101 def compose_body(self) -> list[Widget]: 

102 return [AdoptionTrendBody(self._info, self._range_days)] 

103 

104 

105class AdoptionTrendBody(Static): 

106 def __init__(self, info: PackageInfo, range_days: int) -> None: 

107 super().__init__("", classes="ov-chart-block") 

108 self._info = info 

109 self._range_days = range_days 

110 

111 def on_mount(self) -> None: 

112 self._update_content() 

113 

114 def on_resize(self, event: Resize) -> None: 

115 self._update_content() 

116 

117 def _update_content(self) -> None: 

118 width = self.size.width or 36 

119 max_points = _point_limit(width) 

120 points = _adoption_points( 

121 self._info.download_trend, self._range_days, max_points 

122 ) 

123 if len(points) < 2: 

124 self.update("[dim]No historical adoption data available.[/]") 

125 return 

126 

127 total, pct = _period_download_summary( 

128 self._info.download_trend, self._range_days 

129 ) 

130 trend = _trend_label(pct, points) 

131 trend_color = palette.RED if trend == "Declining" else palette.GREEN 

132 pct_text, pct_color = format_pct_delta(pct) 

133 period_label = _range_label(self._range_days) 

134 chart = _render_line_chart( 

135 points, 

136 width=width, 

137 height=max(3, min(6, self.size.height - 4)), 

138 line_color=trend_color, 

139 ) 

140 self.update( 

141 "\n".join( 

142 [ 

143 chart, 

144 f"[dim]{period_label} Downloads[/]", 

145 f"[b]{shorten_number(total)}[/] [{pct_color}]{pct_text} vs previous period[/]", 

146 f"Trend: [{trend_color}]{trend}[/]", 

147 ] 

148 ) 

149 ) 

150 

151 

152class HealthScorePanel(Panel): 

153 def __init__(self, info: PackageInfo, derived: DerivedPackageData) -> None: 

154 self._health = derived.health_score 

155 super().__init__( 

156 f"HEALTH SCORE ({self._health.total} / 100)", 

157 caption="Derived from package signals", 

158 ) 

159 

160 def compose_body(self) -> list[Widget]: 

161 rows: list[Widget] = [] 

162 for sub in self._health.sub_scores: 

163 frac = sub.score / sub.max_score if sub.max_score else 0 

164 bar = render_bar(frac, width=10) 

165 rows.append( 

166 Static( 

167 f"[dim]{sub.label:<13}[/] {bar} " 

168 f"[b]{sub.score:>2}/{sub.max_score:<2}[/]" 

169 ) 

170 ) 

171 rows.append(Static(f"\n[dim]Signal:[/] {_health_signal(self._health.total)}")) 

172 return rows 

173 

174 

175class EcosystemDistributionPanel(Panel): 

176 def __init__(self, info: PackageInfo, derived: DerivedPackageData) -> None: 

177 self._breakdown = derived.install_breakdown 

178 super().__init__("ECOSYSTEM DISTRIBUTION", caption=self._breakdown.caption) 

179 

180 def compose_body(self) -> list[Widget]: 

181 methods = self._breakdown.methods 

182 if not methods: 

183 return [Static("[dim]No ecosystem download data available.[/]")] 

184 

185 rows: list[Widget] = [] 

186 for method in methods[:5]: 

187 label = _clip(method.label, 12) 

188 bar = render_bar(method.percent / 100, width=12) 

189 rows.append(Static(f"{label:<12} {bar} [b]{method.percent:>4.0f}%[/]")) 

190 

191 primary = methods[0] 

192 rows.append( 

193 Static( 

194 f"\n[dim]Primary:[/] {escape(primary.label)}\n" 

195 f"[dim]Signal:[/] Most users install via {escape(primary.label)}." 

196 ) 

197 ) 

198 return rows 

199 

200 

201class ReverseDependenciesPanel(Panel): 

202 def __init__(self, info: PackageInfo, derived: DerivedPackageData) -> None: 

203 self._summary = derived.reverse_dependency_summary 

204 super().__init__("REVERSE DEPENDENCIES", caption=self._summary.caption) 

205 

206 def compose_body(self) -> list[Widget]: 

207 if self._summary.count is None: 

208 return [Static("[dim]No reverse-dependency data available.[/]")] 

209 

210 growth = self._summary.monthly_growth 

211 if growth is None: 

212 growth_line = "[dim]Monthly growth: —[/]" 

213 signal = "Growth baseline will appear after future snapshots." 

214 else: 

215 color = palette.GREEN if growth >= 0 else palette.RED 

216 sign = "+" if growth >= 0 else "" 

217 growth_line = f"[{color}]▲ {sign}{shorten_number(growth)} this month[/]" 

218 signal = ( 

219 "Library adoption is accelerating." 

220 if growth > 0 

221 else "Library adoption is stable." 

222 if growth == 0 

223 else "Library adoption is contracting." 

224 ) 

225 

226 return [ 

227 Static("[dim]Projects depending on this package[/]"), 

228 Static( 

229 f"[b {palette.GREEN}]{shorten_number(self._summary.count)}[/]", 

230 classes="ov-big-number", 

231 ), 

232 Static(growth_line), 

233 Static(f"\n[dim]Signal:[/] {signal}"), 

234 ] 

235 

236 

237class HealthTimelinePanel(Panel): 

238 def __init__(self, info: PackageInfo, derived: DerivedPackageData) -> None: 

239 self._points = derived.health_timeline 

240 super().__init__("HEALTH TIMELINE", caption="Monthly health score") 

241 

242 def compose_body(self) -> list[Widget]: 

243 return [HealthTimelineBody(self._points)] 

244 

245 

246class HealthTimelineBody(Static): 

247 def __init__(self, points: list[MetricTimelinePoint]) -> None: 

248 super().__init__("", classes="ov-chart-block") 

249 self._points = points 

250 

251 def on_mount(self) -> None: 

252 self._update_content() 

253 

254 def on_resize(self, event: Resize) -> None: 

255 self._update_content() 

256 

257 def _update_content(self) -> None: 

258 width = self.size.width or 36 

259 points = self._points[-_point_limit(width) :] 

260 if len(points) < 2: 

261 self.update("[dim]Health history will appear after future snapshots.[/]") 

262 return 

263 

264 delta = points[-1].value - points[0].value 

265 trend = ( 

266 "Stable" if abs(delta) <= 3 else "Improving" if delta > 0 else "Declining" 

267 ) 

268 color = palette.RED if trend == "Declining" else palette.GREEN 

269 chart = _render_line_chart( 

270 points, 

271 width=width, 

272 height=max(3, min(6, self.size.height - 3)), 

273 line_color=color, 

274 value_floor=0, 

275 value_ceiling=100, 

276 ) 

277 sign = "+" if delta > 0 else "" 

278 self.update( 

279 "\n".join( 

280 [ 

281 chart, 

282 f"Trend: [{color}]{trend}[/]", 

283 f"[dim]{sign}{delta} points since {escape(points[0].label)}[/]", 

284 ] 

285 ) 

286 ) 

287 

288 

289class VersionAdoptionPanel(Panel): 

290 def __init__(self, info: PackageInfo, derived: DerivedPackageData) -> None: 

291 self._info = info 

292 self._derived = derived 

293 caption = derived.adoption_caption or "% = adoption download share" 

294 super().__init__("VERSION ADOPTION", caption=caption) 

295 

296 def compose_body(self) -> list[Widget]: 

297 adoption = self._derived.release_adoption 

298 if not self._info.versions or not adoption: 

299 return [Static("[dim]No version adoption data available.[/]")] 

300 

301 rows: list[Widget] = [] 

302 shown_total = 0.0 

303 for ver in self._info.versions[:4]: 

304 pct = adoption.get(ver.version, 0.0) 

305 shown_total += pct 

306 label = f"v{_clip(ver.version, 8)}" 

307 rows.append(_version_bar(label, pct)) 

308 

309 older = max(0.0, 100.0 - shown_total) 

310 if older >= 0.5: 

311 rows.append(_version_bar("Older", older)) 

312 

313 latest = adoption.get(self._info.versions[0].version, 0.0) 

314 summary = ( 

315 "Healthy" if latest >= 50 else "Fragmented" if latest >= 25 else "Lagging" 

316 ) 

317 rows.append(Static(f"\n[dim]Latest version adoption:[/] {summary}")) 

318 return rows 

319 

320 

321def _version_bar(label: str, pct: float) -> Static: 

322 bar = render_bar(pct / 100, width=14) 

323 return Static(f"{escape(label):<9} {bar} [b]{pct:>4.0f}%[/]") 

324 

325 

326def _adoption_points( 

327 trend: list[DownloadTrendPoint], 

328 days: int, 

329 max_points: int, 

330) -> list[MetricTimelinePoint]: 

331 recent = trend[-days:] if len(trend) > days else trend[:] 

332 if days <= 30: 

333 points = [ 

334 MetricTimelinePoint(label=_short_date_label(p.date), value=p.count) 

335 for p in recent 

336 ] 

337 elif days <= 90: 

338 points = _bucket_by_week(recent) 

339 else: 

340 points = _bucket_by_month(recent) 

341 return _thin_points(points, max_points) 

342 

343 

344def _bucket_by_week(points: list[DownloadTrendPoint]) -> list[MetricTimelinePoint]: 

345 buckets: dict[tuple[int, int], int] = defaultdict(int) 

346 labels: dict[tuple[int, int], str] = {} 

347 for point in points: 

348 parsed = _parse_day(point.date) 

349 if parsed is None: 

350 continue 

351 year, week, _ = parsed.isocalendar() 

352 key = (year, week) 

353 buckets[key] += point.count 

354 labels[key] = f"W{week:02d}" 

355 return [ 

356 MetricTimelinePoint(label=labels[key], value=buckets[key]) 

357 for key in sorted(buckets) 

358 ] 

359 

360 

361def _bucket_by_month(points: list[DownloadTrendPoint]) -> list[MetricTimelinePoint]: 

362 buckets: dict[str, int] = defaultdict(int) 

363 for point in points: 

364 parsed = _parse_day(point.date) 

365 if parsed is None: 

366 continue 

367 buckets[parsed.strftime("%Y-%m")] += point.count 

368 return [ 

369 MetricTimelinePoint(label=_short_month(key), value=buckets[key]) 

370 for key in sorted(buckets) 

371 ] 

372 

373 

374def _thin_points( 

375 points: list[MetricTimelinePoint], 

376 max_points: int, 

377) -> list[MetricTimelinePoint]: 

378 if len(points) <= max_points: 

379 return points 

380 if max_points <= 1: 

381 return points[-1:] 

382 step = (len(points) - 1) / (max_points - 1) 

383 indexes = {round(i * step) for i in range(max_points)} 

384 indexes.add(len(points) - 1) 

385 return [points[i] for i in sorted(indexes)][-max_points:] 

386 

387 

388def _period_download_summary( 

389 trend: list[DownloadTrendPoint], 

390 days: int, 

391) -> tuple[int, float | None]: 

392 if not trend: 

393 return 0, None 

394 current_len = min(days, len(trend)) 

395 current = sum(point.count for point in trend[-current_len:]) 

396 previous_slice = trend[-(current_len * 2) : -current_len] 

397 previous = sum(point.count for point in previous_slice) 

398 if previous <= 0: 

399 return current, None 

400 return current, (current - previous) / previous * 100 

401 

402 

403def _trend_label( 

404 pct: float | None, 

405 points: list[MetricTimelinePoint], 

406) -> str: 

407 if pct is None: 

408 first = points[0].value 

409 last = points[-1].value 

410 pct = None if first <= 0 else (last - first) / first * 100 

411 if pct is None or abs(pct) < 5: 

412 return "Stable" 

413 return "Growing" if pct > 0 else "Declining" 

414 

415 

416def _render_line_chart( 

417 points: list[MetricTimelinePoint], 

418 *, 

419 width: int, 

420 height: int, 

421 line_color: str, 

422 value_floor: int | None = None, 

423 value_ceiling: int | None = None, 

424) -> str: 

425 values = [p.value for p in points] 

426 lo = min(values) if value_floor is None else value_floor 

427 hi = max(values) if value_ceiling is None else value_ceiling 

428 if lo == hi: 

429 hi = lo + 1 

430 

431 left_width = max(4, min(6, max(len(shorten_number(hi)), len(shorten_number(lo))))) 

432 plot_width = max(4, width - left_width - 3) 

433 chart_height = max(3, height) 

434 grid = [[" " for _ in range(plot_width)] for _ in range(chart_height)] 

435 coords: list[tuple[int, int]] = [] 

436 

437 for index, point in enumerate(points): 

438 x = round(index * (plot_width - 1) / max(len(points) - 1, 1)) 

439 ratio = (point.value - lo) / (hi - lo) 

440 y = chart_height - 1 - round(ratio * (chart_height - 1)) 

441 coords.append((x, y)) 

442 

443 for start, end in pairwise(coords): 

444 _draw_segment(grid, start, end) 

445 for x, y in coords: 

446 grid[y][x] = "●" 

447 

448 lines: list[str] = [] 

449 for row, cells in enumerate(grid): 

450 value = round(hi - (hi - lo) * row / max(chart_height - 1, 1)) 

451 axis = "┤" if row < chart_height - 1 else "└" 

452 label = f"{shorten_number(value):>{left_width}}" 

453 lines.append( 

454 f"[{palette.SEPARATOR}]{label} {axis}[/][{line_color}]{''.join(cells)}[/]" 

455 ) 

456 

457 label_row = [" " for _ in range(plot_width)] 

458 occupied: set[int] = set() 

459 for index, (x, _) in enumerate(coords): 

460 label = points[index].label 

461 if index not in (0, len(coords) - 1) and plot_width < len(coords) * 5: 

462 continue 

463 start = min(max(0, x - len(label) // 2), max(0, plot_width - len(label))) 

464 slots = set(range(start, start + len(label))) 

465 if slots & occupied: 

466 continue 

467 occupied.update(slots) 

468 for offset, char in enumerate(label): 

469 label_row[start + offset] = char 

470 lines.append( 

471 " " * (left_width + 2) 

472 + f"[{palette.TEXT_MUTED}]{''.join(label_row).rstrip()}[/]" 

473 ) 

474 return "\n".join(lines) 

475 

476 

477def _draw_segment( 

478 grid: list[list[str]], 

479 start: tuple[int, int], 

480 end: tuple[int, int], 

481) -> None: 

482 x0, y0 = start 

483 x1, y1 = end 

484 steps = max(abs(x1 - x0), abs(y1 - y0), 1) 

485 prev = start 

486 for step in range(steps + 1): 

487 x = round(x0 + (x1 - x0) * step / steps) 

488 y = round(y0 + (y1 - y0) * step / steps) 

489 if (x, y) == start or (x, y) == end: 

490 continue 

491 dy = y - prev[1] 

492 grid[y][x] = "─" if dy == 0 else chr(0x2571) if dy < 0 else chr(0x2572) 

493 prev = (x, y) 

494 

495 

496def _point_limit(width: int) -> int: 

497 if width < 48: 

498 return 5 

499 if width < 72: 

500 return 8 

501 return 12 

502 

503 

504def _parse_day(raw: str) -> datetime | None: 

505 try: 

506 return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc) 

507 except (TypeError, ValueError): 

508 return None 

509 

510 

511def _short_date_label(raw: str) -> str: 

512 parts = raw.split("-") 

513 if len(parts) == 3: 

514 return f"{parts[1]}/{parts[2]}" 

515 return raw[-5:] if len(raw) > 5 else raw 

516 

517 

518def _short_month(raw: str) -> str: 

519 parts = raw.split("-") 

520 if len(parts) == 2: 

521 month = int(parts[1]) 

522 return datetime(2000, month, 1).strftime("%b") 

523 return raw 

524 

525 

526def _range_label(days: int) -> str: 

527 if days <= 30: 

528 return "30d" 

529 if days <= 90: 

530 return "90d" 

531 return "1y" 

532 

533 

534def _clip(value: str, max_len: int) -> str: 

535 return value if len(value) <= max_len else value[: max_len - 1] + "…" 

536 

537 

538def _health_signal(total: int) -> str: 

539 if total >= 85: 

540 return "Well maintained." 

541 if total >= 65: 

542 return "Generally healthy." 

543 if total >= 45: 

544 return "Mixed maintenance signals." 

545 return "Needs attention."