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

332 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-16 12:16 +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 UTC, datetime 

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 captions = { 

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

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

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

37 Registry.PUB: "Source: pub.dev (30-day rolling count)", 

38 } 

39 return captions.get(registry, f"Source: {registry.display_name}") 

40 

41 

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

43 return info.source_registries or [info.registry] 

44 

45 

46class OverviewTab(Vertical): 

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

48 

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

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

51 self._info = info 

52 self._derived = derived 

53 self._range_days = 30 

54 

55 def compose(self) -> ComposeResult: 

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

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

58 for label, days in _RANGES: 

59 classes = "overview-range-button" 

60 if days == self._range_days: 

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

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

63 

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

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

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

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

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

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

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

71 

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

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

74 button_id = event.button.id or "" 

75 prefix = "overview-range-" 

76 if not button_id.startswith(prefix): 

77 return 

78 try: 

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

80 except ValueError: 

81 return 

82 event.stop() 

83 self.refresh(recompose=True) 

84 

85 

86class AdoptionTrendPanel(Panel): 

87 def __init__( 

88 self, 

89 info: PackageInfo, 

90 derived: DerivedPackageData, 

91 range_days: int, 

92 ) -> None: 

93 self._info = info 

94 self._range_days = range_days 

95 registries = _source_registries(info) 

96 caption = ( 

97 "Source: combined registry downloads" 

98 if len(registries) > 1 

99 else _downloads_source(info.registry) 

100 ) 

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

102 

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

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

105 

106 

107class AdoptionTrendBody(Static): 

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

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

110 self._info = info 

111 self._range_days = range_days 

112 

113 def on_mount(self) -> None: 

114 self._update_content() 

115 

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

117 self._update_content() 

118 

119 def _update_content(self) -> None: 

120 width = self.size.width or 36 

121 max_points = _point_limit(width) 

122 points = _adoption_points( 

123 self._info.download_trend, self._range_days, max_points 

124 ) 

125 if len(points) < 2: 

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

127 return 

128 

129 total, pct = _period_download_summary( 

130 self._info.download_trend, self._range_days 

131 ) 

132 trend = _trend_label(pct, points) 

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

134 pct_text, pct_color = format_pct_delta(pct) 

135 period_label = _range_label(self._range_days) 

136 chart = _render_line_chart( 

137 points, 

138 width=width, 

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

140 line_color=trend_color, 

141 ) 

142 self.update( 

143 "\n".join( 

144 [ 

145 chart, 

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

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

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

149 ] 

150 ) 

151 ) 

152 

153 

154class HealthScorePanel(Panel): 

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

156 self._health = derived.health_score 

157 super().__init__( 

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

159 caption="Derived from package signals", 

160 ) 

161 

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

163 rows: list[Widget] = [] 

164 for sub in self._health.sub_scores: 

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

166 bar = render_bar(frac, width=10) 

167 rows.append( 

168 Static( 

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

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

171 ) 

172 ) 

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

174 return rows 

175 

176 

177class EcosystemDistributionPanel(Panel): 

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

179 self._breakdown = derived.install_breakdown 

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

181 

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

183 methods = self._breakdown.methods 

184 if not methods: 

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

186 

187 rows: list[Widget] = [] 

188 for method in methods[:5]: 

189 label = _clip(method.label, 12) 

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

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

192 

193 primary = methods[0] 

194 sources = ", ".join(method.label for method in methods) 

195 rows.append( 

196 Static( 

197 f"\n[dim]Sources:[/] {escape(sources)}\n" 

198 f"[dim]Signal:[/] Highest observed activity: {escape(primary.label)}." 

199 ) 

200 ) 

201 return rows 

202 

203 

204class ReverseDependenciesPanel(Panel): 

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

206 self._summary = derived.reverse_dependency_summary 

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

208 

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

210 if self._summary.count is None: 

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

212 

213 growth = self._summary.monthly_growth 

214 if growth is None: 

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

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

217 else: 

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

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

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

221 signal = ( 

222 "Library adoption is accelerating." 

223 if growth > 0 

224 else "Library adoption is stable." 

225 if growth == 0 

226 else "Library adoption is contracting." 

227 ) 

228 

229 return [ 

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

231 Static( 

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

233 classes="ov-big-number", 

234 ), 

235 Static(growth_line), 

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

237 ] 

238 

239 

240class HealthTimelinePanel(Panel): 

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

242 self._points = derived.health_timeline 

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

244 

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

246 return [HealthTimelineBody(self._points)] 

247 

248 

249class HealthTimelineBody(Static): 

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

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

252 self._points = points 

253 

254 def on_mount(self) -> None: 

255 self._update_content() 

256 

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

258 self._update_content() 

259 

260 def _update_content(self) -> None: 

261 width = self.size.width or 36 

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

263 if len(points) < 2: 

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

265 return 

266 

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

268 trend = ( 

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

270 ) 

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

272 chart = _render_line_chart( 

273 points, 

274 width=width, 

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

276 line_color=color, 

277 value_floor=0, 

278 value_ceiling=100, 

279 ) 

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

281 self.update( 

282 "\n".join( 

283 [ 

284 chart, 

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

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

287 ] 

288 ) 

289 ) 

290 

291 

292class VersionAdoptionPanel(Panel): 

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

294 self._info = info 

295 self._derived = derived 

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

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

298 

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

300 adoption = self._derived.release_adoption 

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

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

303 

304 rows: list[Widget] = [] 

305 shown_total = 0.0 

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

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

308 shown_total += pct 

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

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

311 

312 older = max(0.0, 100.0 - shown_total) 

313 if older >= 0.5: 

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

315 

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

317 summary = ( 

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

319 ) 

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

321 return rows 

322 

323 

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

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

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

327 

328 

329def _adoption_points( 

330 trend: list[DownloadTrendPoint], 

331 days: int, 

332 max_points: int, 

333) -> list[MetricTimelinePoint]: 

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

335 if days <= 30: 

336 points = [ 

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

338 for p in recent 

339 ] 

340 elif days <= 90: 

341 points = _bucket_by_week(recent) 

342 else: 

343 points = _bucket_by_month(recent) 

344 return _thin_points(points, max_points) 

345 

346 

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

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

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

350 for point in points: 

351 parsed = _parse_day(point.date) 

352 if parsed is None: 

353 continue 

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

355 key = (year, week) 

356 buckets[key] += point.count 

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

358 return [ 

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

360 for key in sorted(buckets) 

361 ] 

362 

363 

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

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

366 for point in points: 

367 parsed = _parse_day(point.date) 

368 if parsed is None: 

369 continue 

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

371 return [ 

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

373 for key in sorted(buckets) 

374 ] 

375 

376 

377def _thin_points( 

378 points: list[MetricTimelinePoint], 

379 max_points: int, 

380) -> list[MetricTimelinePoint]: 

381 if len(points) <= max_points: 

382 return points 

383 if max_points <= 1: 

384 return points[-1:] 

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

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

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

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

389 

390 

391def _period_download_summary( 

392 trend: list[DownloadTrendPoint], 

393 days: int, 

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

395 if not trend: 

396 return 0, None 

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

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

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

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

401 if previous <= 0: 

402 return current, None 

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

404 

405 

406def _trend_label( 

407 pct: float | None, 

408 points: list[MetricTimelinePoint], 

409) -> str: 

410 if pct is None: 

411 first = points[0].value 

412 last = points[-1].value 

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

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

415 return "Stable" 

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

417 

418 

419def _render_line_chart( 

420 points: list[MetricTimelinePoint], 

421 *, 

422 width: int, 

423 height: int, 

424 line_color: str, 

425 value_floor: int | None = None, 

426 value_ceiling: int | None = None, 

427) -> str: 

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

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

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

431 if lo == hi: 

432 hi = lo + 1 

433 

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

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

436 chart_height = max(3, height) 

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

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

439 

440 for index, point in enumerate(points): 

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

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

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

444 coords.append((x, y)) 

445 

446 for start, end in pairwise(coords): 

447 _draw_segment(grid, start, end) 

448 for x, y in coords: 

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

450 

451 lines: list[str] = [] 

452 for row, cells in enumerate(grid): 

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

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

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

456 lines.append( 

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

458 ) 

459 

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

461 occupied: set[int] = set() 

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

463 label = points[index].label 

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

465 continue 

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

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

468 if slots & occupied: 

469 continue 

470 occupied.update(slots) 

471 for offset, char in enumerate(label): 

472 label_row[start + offset] = char 

473 lines.append( 

474 " " * (left_width + 2) 

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

476 ) 

477 return "\n".join(lines) 

478 

479 

480def _draw_segment( 

481 grid: list[list[str]], 

482 start: tuple[int, int], 

483 end: tuple[int, int], 

484) -> None: 

485 x0, y0 = start 

486 x1, y1 = end 

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

488 prev = start 

489 for step in range(steps + 1): 

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

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

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

493 continue 

494 dy = y - prev[1] 

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

496 prev = (x, y) 

497 

498 

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

500 if width < 48: 

501 return 5 

502 if width < 72: 

503 return 8 

504 return 12 

505 

506 

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

508 try: 

509 return datetime.fromisoformat(raw).replace(tzinfo=UTC) 

510 except (TypeError, ValueError): 

511 return None 

512 

513 

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

515 parts = raw.split("-") 

516 if len(parts) == 3: 

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

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

519 

520 

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

522 parts = raw.split("-") 

523 if len(parts) == 2: 

524 month = int(parts[1]) 

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

526 return raw 

527 

528 

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

530 if days <= 30: 

531 return "30d" 

532 if days <= 90: 

533 return "90d" 

534 return "1y" 

535 

536 

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

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

539 

540 

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

542 if total >= 85: 

543 return "Well maintained." 

544 if total >= 65: 

545 return "Generally healthy." 

546 if total >= 45: 

547 return "Mixed maintenance signals." 

548 return "Needs attention."