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
« 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."""
3from __future__ import annotations
5from collections import defaultdict
6from datetime import datetime, timezone
7from itertools import pairwise
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
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
29_RANGES: tuple[tuple[str, int], ...] = (("30d", 30), ("90d", 90), ("1y", 365))
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]
40def _source_registries(info: PackageInfo) -> list[Registry]:
41 return info.source_registries or [info.registry]
44class OverviewTab(Vertical):
45 """Composes the Overview dashboard into a two-row, three-column grid."""
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
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)
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)
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)
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)
101 def compose_body(self) -> list[Widget]:
102 return [AdoptionTrendBody(self._info, self._range_days)]
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
111 def on_mount(self) -> None:
112 self._update_content()
114 def on_resize(self, event: Resize) -> None:
115 self._update_content()
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
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 )
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 )
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
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)
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.[/]")]
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}%[/]"))
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
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)
206 def compose_body(self) -> list[Widget]:
207 if self._summary.count is None:
208 return [Static("[dim]No reverse-dependency data available.[/]")]
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 )
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 ]
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")
242 def compose_body(self) -> list[Widget]:
243 return [HealthTimelineBody(self._points)]
246class HealthTimelineBody(Static):
247 def __init__(self, points: list[MetricTimelinePoint]) -> None:
248 super().__init__("", classes="ov-chart-block")
249 self._points = points
251 def on_mount(self) -> None:
252 self._update_content()
254 def on_resize(self, event: Resize) -> None:
255 self._update_content()
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
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 )
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)
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.[/]")]
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))
309 older = max(0.0, 100.0 - shown_total)
310 if older >= 0.5:
311 rows.append(_version_bar("Older", older))
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
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}%[/]")
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)
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 ]
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 ]
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:]
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
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"
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
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]] = []
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))
443 for start, end in pairwise(coords):
444 _draw_segment(grid, start, end)
445 for x, y in coords:
446 grid[y][x] = "●"
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 )
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)
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)
496def _point_limit(width: int) -> int:
497 if width < 48:
498 return 5
499 if width < 72:
500 return 8
501 return 12
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
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
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
526def _range_label(days: int) -> str:
527 if days <= 30:
528 return "30d"
529 if days <= 90:
530 return "90d"
531 return "1y"
534def _clip(value: str, max_len: int) -> str:
535 return value if len(value) <= max_len else value[: max_len - 1] + "…"
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."