gitpulse-analytics 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gitpulse/report.py ADDED
@@ -0,0 +1,623 @@
1
+ """Render an OrgReport as a self-contained HTML dashboard (no external scripts or styles)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from html import escape
8
+ from typing import Any
9
+
10
+ from gitpulse.org import OrgReport, RepoStats
11
+
12
+ # Categorical slots in fixed order; slot 4 (yellow) takes dark ink for labels inside it.
13
+ SERIES = 8
14
+ DARK_INK_SLOTS = {4}
15
+
16
+
17
+ def compact(value: float) -> str:
18
+ """Format 1284 -> 1,284 and 12940 -> 12.9K."""
19
+ for threshold, divisor, suffix in ((1e9, 1e9, "B"), (1e6, 1e6, "M"), (1e4, 1e3, "K")):
20
+ if abs(value) >= threshold:
21
+ return f"{value / divisor:.1f}".rstrip("0").rstrip(".") + suffix
22
+ return f"{int(value):,}"
23
+
24
+
25
+ def nice_ticks(maximum: int, count: int = 4) -> list[int]:
26
+ """Round axis ticks from zero up to a clean value at or above MAXIMUM."""
27
+ if maximum <= 0:
28
+ return [0, 1]
29
+ raw = maximum / count
30
+ magnitude = 10 ** math.floor(math.log10(raw))
31
+ step = next(m * magnitude for m in (1, 2, 2.5, 5, 10) if m * magnitude >= raw)
32
+ step = max(1, int(step))
33
+ top = step * math.ceil(maximum / step)
34
+ return list(range(0, top + 1, step))
35
+
36
+
37
+ def _attr(text: Any) -> str:
38
+ return escape(str(text), quote=True)
39
+
40
+
41
+ def _short_date(day: str) -> str:
42
+ months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
43
+ return f"{months[int(day[5:7]) - 1]} {int(day[8:10])}"
44
+
45
+
46
+ def _stat_tile(label: str, value: int, note: str = "") -> str:
47
+ note_html = f'<div class="tile-note">{escape(note)}</div>' if note else ""
48
+ return (
49
+ f'<div class="tile"><div class="tile-label">{escape(label)}</div>'
50
+ f'<div class="tile-value" title="{value:,}">{compact(value)}</div>{note_html}</div>'
51
+ )
52
+
53
+
54
+ def _bar_list(rows: list[tuple[str, int, str, str]], empty: str) -> str:
55
+ """Horizontal bars. Each row is (label, value, link, tooltip)."""
56
+ if not rows:
57
+ return f'<p class="empty">{escape(empty)}</p>'
58
+ peak = max(value for _, value, _, _ in rows) or 1
59
+ items = []
60
+ for label, value, link, tip in rows:
61
+ name = (
62
+ f'<a href="{_attr(link)}" target="_blank" rel="noopener">{escape(label)}</a>'
63
+ if link
64
+ else escape(label)
65
+ )
66
+ width = max(0.5, value / peak * 100)
67
+ items.append(
68
+ f'<li class="bar-row" tabindex="0" data-tip="{_attr(tip)}">'
69
+ f'<span class="bar-label">{name}</span>'
70
+ f'<span class="bar-track"><span class="bar" style="width:{width:.2f}%"></span></span>'
71
+ f'<span class="bar-value">{compact(value)}</span></li>'
72
+ )
73
+ return f'<ul class="bars">{"".join(items)}</ul>'
74
+
75
+
76
+ def _line_chart(series: list[tuple[str, str, list[tuple[str, int]]]]) -> str:
77
+ """Daily views/clones on one count axis. Each series is (name, css var, [(day, count)])."""
78
+ days = [day for day, _ in series[0][2]] if series and series[0][2] else []
79
+ if not days:
80
+ return '<p class="empty">No traffic data available. The token may lack Administration: Read.</p>'
81
+ width, height = 1000, 300
82
+ peak = max((count for _, _, points in series for _, count in points), default=0)
83
+ ticks = nice_ticks(peak)
84
+ top = ticks[-1]
85
+ step_x = width / max(1, len(days) - 1)
86
+
87
+ def xy(i: int, count: int) -> tuple[float, float]:
88
+ return i * step_x, height - (count / top * height)
89
+
90
+ grid = "".join(
91
+ f'<line class="grid" x1="0" x2="{width}" y1="{height - t / top * height:.1f}" '
92
+ f'y2="{height - t / top * height:.1f}" />'
93
+ for t in ticks
94
+ )
95
+ paths, end_labels, legend = [], [], []
96
+ for index, (name, var, points) in enumerate(series):
97
+ coords = [xy(i, count) for i, (_, count) in enumerate(points)]
98
+ line = " ".join(f"{x:.1f},{y:.1f}" for x, y in coords)
99
+ area = f"0,{height} {line} {width},{height}"
100
+ if index == 0:
101
+ paths.append(f'<polygon class="area" points="{area}" style="fill:var({var})" />')
102
+ paths.append(f'<polyline class="line" points="{line}" style="stroke:var({var})" />')
103
+ last = points[-1][1]
104
+ end_labels.append(
105
+ f'<span class="end-label" style="top:{(1 - last / top) * 100:.2f}%">'
106
+ f'{compact(last)}</span>'
107
+ )
108
+ legend.append(
109
+ f'<span class="legend-item"><span class="key-line" style="background:var({var})"></span>'
110
+ f"{escape(name)}</span>"
111
+ )
112
+ y_labels = "".join(
113
+ f'<span class="y-tick" style="top:{(1 - t / top) * 100:.2f}%">{compact(t)}</span>'
114
+ for t in ticks
115
+ )
116
+ label_every = max(1, math.ceil(len(days) / 7))
117
+ # Every other label carries .alt so narrow screens can drop it.
118
+ x_labels = "".join(
119
+ f'<span class="x-tick{" alt" if (len(days) - 1 - i) // label_every % 2 else ""}" '
120
+ f'style="left:{i / max(1, len(days) - 1) * 100:.2f}%">{_short_date(day)}</span>'
121
+ for i, day in enumerate(days)
122
+ if (len(days) - 1 - i) % label_every == 0
123
+ )
124
+ data = {
125
+ "days": days,
126
+ "top": top,
127
+ "series": [
128
+ {"name": name, "var": var, "values": [count for _, count in points]}
129
+ for name, var, points in series
130
+ ],
131
+ }
132
+ return f"""
133
+ <div class="legend">{"".join(legend)}</div>
134
+ <div class="linechart" data-chart='{_attr(json.dumps(data))}'>
135
+ <div class="y-axis">{y_labels}</div>
136
+ <div class="plot" tabindex="0" aria-label="Daily views and clones across all repositories">
137
+ <svg viewBox="0 0 {width} {height}" preserveAspectRatio="none" aria-hidden="true">
138
+ {grid}{"".join(paths)}
139
+ </svg>
140
+ <div class="end-labels">{"".join(end_labels)}</div>
141
+ <div class="crosshair" hidden></div>
142
+ </div>
143
+ <div class="x-axis">{x_labels}</div>
144
+ </div>"""
145
+
146
+
147
+ def _languages(report: OrgReport) -> str:
148
+ langs = report.languages()
149
+ if not langs:
150
+ return '<p class="empty">No repositories.</p>'
151
+ shown = langs[: SERIES - 1]
152
+ other = sum(count for _, count in langs[SERIES - 1 :])
153
+ if other:
154
+ shown.append(("Other", other))
155
+ total = sum(count for _, count in shown)
156
+ blocks, legend = [], []
157
+ for slot, (name, count) in enumerate(shown, start=1):
158
+ var = "--muted-fill" if name == "Other" else f"--series-{slot}"
159
+ share = count / total * 100
160
+ ink = " dark-ink" if slot in DARK_INK_SLOTS and name != "Other" else ""
161
+ label = f"{share:.0f}%" if share >= 9 else ""
162
+ blocks.append(
163
+ f'<span class="segment{ink}" tabindex="0" style="flex:{count};background:var({var})" '
164
+ f'data-tip="{_attr(f"{name}: {count} repos ({share:.0f}%)")}">{label}</span>'
165
+ )
166
+ legend.append(
167
+ f'<span class="legend-item"><span class="swatch" style="background:var({var})"></span>'
168
+ f"{escape(name)} <span class='muted'>{count}</span></span>"
169
+ )
170
+ return (
171
+ f'<div class="segmented" role="img" aria-label="Repositories by primary language">'
172
+ f'{"".join(blocks)}</div><div class="legend wrap">{"".join(legend)}</div>'
173
+ )
174
+
175
+
176
+ def _sparkline(values: list[int]) -> str:
177
+ if not values or not any(values):
178
+ return '<span class="muted">—</span>'
179
+ peak = max(values)
180
+ step = 100 / max(1, len(values) - 1)
181
+ points = " ".join(f"{i * step:.1f},{22 - v / peak * 20:.1f}" for i, v in enumerate(values))
182
+ return (
183
+ '<svg class="spark" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true">'
184
+ f'<polyline points="{points}" /></svg>'
185
+ )
186
+
187
+
188
+ def _repo_row(repo: RepoStats, days: list[str]) -> str:
189
+ def num(value: int, available: bool = True) -> str:
190
+ if not available:
191
+ return '<td class="num muted" data-sort="-1">n/a</td>'
192
+ return f'<td class="num" data-sort="{value}">{value:,}</td>'
193
+
194
+ traffic = repo.traffic_error is None
195
+ badges = "".join(
196
+ f'<span class="badge">{label}</span>'
197
+ for label, on in (("private", repo.private), ("fork", repo.fork), ("archived", repo.archived))
198
+ if on
199
+ )
200
+ spark = _sparkline([repo.views_daily.get(day, 0) for day in days]) if traffic else ""
201
+ return f"""<tr>
202
+ <td data-sort="{_attr(repo.name.lower())}"><a href="{_attr(repo.url)}" target="_blank" rel="noopener">{escape(repo.name)}</a>{badges}
203
+ <div class="desc">{escape(repo.description)}</div></td>
204
+ <td data-sort="{_attr(repo.language.lower())}">{escape(repo.language) or '<span class="muted">—</span>'}</td>
205
+ {num(repo.stars)}{num(repo.forks)}{num(repo.open_issues)}
206
+ {num(repo.views, traffic)}{num(repo.view_uniques, traffic)}{num(repo.clones, traffic)}
207
+ {num(repo.downloads, repo.releases_error is None)}
208
+ <td class="spark-cell">{spark}</td>
209
+ <td data-sort="{_attr(repo.pushed_at)}" class="nowrap">{escape(repo.pushed_at[:10])}</td>
210
+ </tr>"""
211
+
212
+
213
+ def render_html(report: OrgReport) -> str:
214
+ owner = report.owner
215
+ profile = report.profile
216
+ display = profile.get("name") or owner
217
+ views_daily = report.daily("views_daily")
218
+ clones_daily = report.daily("clones_daily")
219
+ days = [day for day, _ in views_daily]
220
+ window = f"{_short_date(days[0])} – {_short_date(days[-1])}" if days else "no traffic data"
221
+ traffic_count = len(report.traffic_repos)
222
+ missing = [repo for repo in report.repos if repo.traffic_error]
223
+ avatar = profile.get("avatar_url")
224
+ avatar_html = f'<img class="avatar" src="{_attr(avatar)}" alt="" />' if avatar else ""
225
+ generated = report.generated_at.strftime("%Y-%m-%d %H:%M UTC")
226
+
227
+ tiles = "".join(
228
+ [
229
+ _stat_tile("Unique visitors", report.total("view_uniques"), "summed per repo"),
230
+ _stat_tile("Clones", report.total("clones"), f"{report.total('clone_uniques'):,} unique cloners"),
231
+ _stat_tile("Stars", report.total("stars")),
232
+ _stat_tile("Forks", report.total("forks")),
233
+ _stat_tile("Release downloads", report.total("downloads"), "all-time, uploaded assets"),
234
+ _stat_tile("Open issues & PRs", report.total("open_issues")),
235
+ ]
236
+ )
237
+
238
+ def repo_bars(attr: str, unit: str) -> list[tuple[str, int, str, str]]:
239
+ return [
240
+ (repo.name, getattr(repo, attr), repo.url, f"{repo.full_name}: {getattr(repo, attr):,} {unit}")
241
+ for repo in report.top(attr)
242
+ ]
243
+
244
+ referrer_rows = [
245
+ (name, count, "", f"{name}: {count:,} views, {uniques:,} unique (summed per repo)")
246
+ for name, count, uniques in report.referrers()
247
+ ]
248
+ rows = "".join(_repo_row(repo, days) for repo in report.repos)
249
+ missing_html = ""
250
+ if missing:
251
+ items = "".join(
252
+ f"<li><strong>{escape(repo.full_name)}</strong>: {escape(repo.traffic_error or '')}</li>"
253
+ for repo in missing
254
+ )
255
+ missing_html = (
256
+ f'<details class="card notice"><summary>Traffic unavailable for {len(missing)} of '
257
+ f"{len(report.repos)} repositories</summary><ul>{items}</ul></details>"
258
+ )
259
+
260
+ line = _line_chart(
261
+ [("Views", "--series-1", views_daily), ("Clones", "--series-2", clones_daily)]
262
+ )
263
+ return TEMPLATE.format(
264
+ title=escape(f"{display} · GitPulse"),
265
+ avatar=avatar_html,
266
+ display=escape(display),
267
+ owner=escape(owner),
268
+ owner_url=_attr(profile.get("html_url") or f"https://github.com/{owner}"),
269
+ generated=generated,
270
+ repo_count=len(report.repos),
271
+ traffic_count=traffic_count,
272
+ window=window,
273
+ hero=compact(report.total("views")),
274
+ hero_full=f"{report.total('views'):,}",
275
+ tiles=tiles,
276
+ line=line,
277
+ top_views=_bar_list(repo_bars("views", "views"), "No views in the traffic window."),
278
+ top_stars=_bar_list(repo_bars("stars", "stars"), "No stars yet."),
279
+ languages=_languages(report),
280
+ referrers=_bar_list(referrer_rows, "No referrers in the traffic window."),
281
+ downloads=_bar_list(repo_bars("downloads", "downloads"), "No release asset downloads."),
282
+ rows=rows,
283
+ missing=missing_html,
284
+ css=CSS,
285
+ js=JS,
286
+ )
287
+
288
+
289
+ CSS = """
290
+ :root {
291
+ color-scheme: light;
292
+ --page: #f9f9f7; --surface: #fcfcfb; --text: #0b0b0b; --text-2: #52514e; --muted: #898781;
293
+ --grid: #e1e0d9; --axis: #c3c2b7; --border: rgba(11,11,11,0.10); --wash: rgba(11,11,11,0.04);
294
+ --accent: #2a78d6; --muted-fill: #b4b2a9;
295
+ --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --series-4: #eda100;
296
+ --series-5: #e87ba4; --series-6: #008300; --series-7: #6250d6; --series-8: #e34948;
297
+ }
298
+ @media (prefers-color-scheme: dark) {
299
+ :root:not([data-theme="light"]) {
300
+ color-scheme: dark;
301
+ --page: #0d0d0d; --surface: #1a1a19; --text: #f0efec; --text-2: #c3c2b7; --muted: #898781;
302
+ --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,0.10); --wash: rgba(255,255,255,0.05);
303
+ --accent: #3987e5; --muted-fill: #5f5e5a;
304
+ --series-1: #3987e5; --series-2: #d95926; --series-3: #199e70; --series-4: #c98500;
305
+ --series-5: #d55181; --series-6: #008300; --series-7: #9085e9; --series-8: #e66767;
306
+ }
307
+ }
308
+ :root[data-theme="dark"] {
309
+ color-scheme: dark;
310
+ --page: #0d0d0d; --surface: #1a1a19; --text: #f0efec; --text-2: #c3c2b7; --muted: #898781;
311
+ --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,0.10); --wash: rgba(255,255,255,0.05);
312
+ --accent: #3987e5; --muted-fill: #5f5e5a;
313
+ --series-1: #3987e5; --series-2: #d95926; --series-3: #199e70; --series-4: #c98500;
314
+ --series-5: #d55181; --series-6: #008300; --series-7: #9085e9; --series-8: #e66767;
315
+ }
316
+ * { box-sizing: border-box; }
317
+ body { margin: 0; background: var(--page); color: var(--text);
318
+ font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
319
+ a { color: inherit; text-decoration: none; }
320
+ a:hover { text-decoration: underline; }
321
+ .wrap-page { max-width: 1200px; margin: 0 auto; padding: 32px 24px 64px; }
322
+ header { display: flex; align-items: center; gap: 16px; margin-bottom: 28px; }
323
+ .avatar { width: 56px; height: 56px; border-radius: 12px; border: 1px solid var(--border); }
324
+ h1 { font-size: 24px; margin: 0; font-weight: 650; letter-spacing: -0.01em; }
325
+ h2 { font-size: 15px; margin: 0 0 4px; font-weight: 600; }
326
+ .sub { color: var(--text-2); font-size: 13px; }
327
+ .theme-toggle { margin-left: auto; flex-shrink: 0; white-space: nowrap; background: var(--surface); color: var(--text-2);
328
+ border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; font: inherit; cursor: pointer; }
329
+ .theme-toggle:hover { background: var(--wash); }
330
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 20px; }
331
+ .card-sub { color: var(--muted); font-size: 12px; margin-bottom: 16px; }
332
+ .overview { display: grid; grid-template-columns: minmax(220px, 1fr) 3fr; gap: 16px; margin-bottom: 16px; }
333
+ .hero-label { color: var(--text-2); font-size: 13px; }
334
+ .hero-value { font-size: 56px; font-weight: 650; line-height: 1.1; letter-spacing: -0.02em; margin: 6px 0; }
335
+ .tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border);
336
+ border-radius: 14px; overflow: hidden; border: 1px solid var(--border); }
337
+ .tile { background: var(--surface); padding: 16px 20px; }
338
+ .tile-label { color: var(--text-2); font-size: 13px; }
339
+ .tile-value { font-size: 26px; font-weight: 600; margin-top: 2px; }
340
+ .tile-note { color: var(--muted); font-size: 12px; }
341
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
342
+ .full { margin-top: 16px; }
343
+ .legend { display: flex; gap: 16px; font-size: 12px; color: var(--text-2); margin-bottom: 10px; }
344
+ .legend.wrap { flex-wrap: wrap; gap: 6px 16px; margin: 12px 0 0; }
345
+ .legend-item { display: inline-flex; align-items: center; gap: 6px; }
346
+ .key-line { width: 14px; height: 2px; border-radius: 1px; }
347
+ .swatch { width: 10px; height: 10px; border-radius: 3px; }
348
+ .muted { color: var(--muted); }
349
+ .empty { color: var(--muted); margin: 24px 0; text-align: center; }
350
+ .linechart { display: grid; grid-template-columns: 44px 1fr; grid-template-rows: 240px auto; }
351
+ .y-axis { position: relative; }
352
+ .y-tick { position: absolute; right: 8px; transform: translateY(-50%); font-size: 11px;
353
+ color: var(--muted); font-variant-numeric: tabular-nums; }
354
+ .plot { position: relative; outline: none; margin-right: 40px; }
355
+ .plot svg { width: 100%; height: 100%; display: block; overflow: visible; }
356
+ .plot .grid { stroke: var(--grid); stroke-width: 1; vector-effect: non-scaling-stroke; }
357
+ .plot .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round;
358
+ vector-effect: non-scaling-stroke; }
359
+ .plot .area { opacity: 0.10; }
360
+ .end-labels { position: absolute; top: 0; bottom: 0; left: 100%; }
361
+ .end-label { position: absolute; left: 8px; transform: translateY(-50%); font-size: 11px;
362
+ color: var(--text-2); font-variant-numeric: tabular-nums; }
363
+ .x-axis { grid-column: 2; position: relative; height: 22px; margin-right: 40px; }
364
+ .x-tick { position: absolute; top: 6px; transform: translateX(-50%); font-size: 11px; color: var(--muted);
365
+ white-space: nowrap; }
366
+ .crosshair { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--axis); pointer-events: none; }
367
+ .dot { position: absolute; width: 10px; height: 10px; border-radius: 50%; transform: translate(-50%, -50%);
368
+ box-shadow: 0 0 0 2px var(--surface); pointer-events: none; }
369
+ .bars { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
370
+ .bar-row { display: grid; grid-template-columns: minmax(90px, min(34%, 220px)) 1fr 52px; align-items: center; gap: 10px;
371
+ padding: 5px 6px; border-radius: 6px; outline: none; }
372
+ .bar-row:hover, .bar-row:focus-visible { background: var(--wash); }
373
+ .bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
374
+ .bar-track { height: 12px; }
375
+ .bar { display: block; height: 100%; background: var(--series-1); border-radius: 0 4px 4px 0; }
376
+ .bar-value { text-align: right; font-variant-numeric: tabular-nums; }
377
+ .segmented { display: flex; gap: 2px; height: 28px; }
378
+ .segment { min-width: 3px; border-radius: 4px; color: #fff; font-size: 12px; font-weight: 600;
379
+ padding-left: 8px; display: flex; align-items: center; overflow: hidden; outline: none; }
380
+ .segment.dark-ink { color: #0b0b0b; }
381
+ .segment:hover, .segment:focus-visible { filter: brightness(1.08); }
382
+ .table-head { display: flex; align-items: end; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
383
+ .search { background: var(--page); color: var(--text); border: 1px solid var(--border); border-radius: 8px;
384
+ padding: 7px 10px; font: inherit; min-width: 220px; }
385
+ .table-scroll { overflow-x: auto; margin-top: 12px; }
386
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
387
+ th { text-align: left; font-weight: 600; color: var(--text-2); font-size: 12px; padding: 8px 10px;
388
+ border-bottom: 1px solid var(--axis); cursor: pointer; user-select: none; white-space: nowrap; }
389
+ th.num { text-align: right; }
390
+ th[aria-sort]::after { content: " ↓"; color: var(--muted); }
391
+ th[aria-sort="ascending"]::after { content: " ↑"; }
392
+ td { padding: 9px 10px; border-bottom: 1px solid var(--grid); vertical-align: top; }
393
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
394
+ tr:hover td { background: var(--wash); }
395
+ .desc { color: var(--muted); font-size: 12px; max-width: 360px; overflow: hidden; text-overflow: ellipsis;
396
+ white-space: nowrap; }
397
+ .badge { font-size: 10px; color: var(--text-2); border: 1px solid var(--border); border-radius: 999px;
398
+ padding: 0 6px; margin-left: 6px; vertical-align: 1px; }
399
+ .nowrap { white-space: nowrap; }
400
+ .spark-cell { width: 110px; }
401
+ .spark { width: 100px; height: 24px; display: block; }
402
+ .spark polyline { fill: none; stroke: var(--series-1); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
403
+ .notice { margin-top: 16px; color: var(--text-2); font-size: 13px; }
404
+ .notice summary { cursor: pointer; }
405
+ .notice li { margin: 4px 0; }
406
+ footer { color: var(--muted); font-size: 12px; margin-top: 24px; }
407
+ #tooltip { position: fixed; z-index: 10; pointer-events: none; background: var(--surface); color: var(--text);
408
+ border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: 12px;
409
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12); max-width: 280px; }
410
+ #tooltip .tt-row { display: flex; align-items: center; gap: 6px; }
411
+ #tooltip .tt-val { margin-left: auto; padding-left: 12px; font-variant-numeric: tabular-nums; font-weight: 600; }
412
+ @media (max-width: 860px) {
413
+ .overview, .grid-2 { grid-template-columns: 1fr; }
414
+ .tiles { grid-template-columns: repeat(2, 1fr); }
415
+ }
416
+ @media (max-width: 520px) {
417
+ .wrap-page { padding: 20px 16px 48px; }
418
+ .hero-value { font-size: 48px; }
419
+ .search { min-width: 0; width: 100%; }
420
+ h1 { font-size: 20px; }
421
+ .avatar { width: 40px; height: 40px; }
422
+ .theme-toggle { padding: 5px 9px; font-size: 12px; }
423
+ .x-tick.alt { display: none; }
424
+ }
425
+ """
426
+
427
+ JS = r"""
428
+ (() => {
429
+ const root = document.documentElement;
430
+ const toggle = document.querySelector('.theme-toggle');
431
+ const prefersDark = () => matchMedia('(prefers-color-scheme: dark)').matches;
432
+ const current = () => root.dataset.theme || (prefersDark() ? 'dark' : 'light');
433
+ try { const saved = localStorage.getItem('gitpulse-theme'); if (saved) root.dataset.theme = saved; } catch (e) {}
434
+ const label = () => { toggle.textContent = current() === 'dark' ? 'Light mode' : 'Dark mode'; };
435
+ toggle.addEventListener('click', () => {
436
+ root.dataset.theme = current() === 'dark' ? 'light' : 'dark';
437
+ try { localStorage.setItem('gitpulse-theme', root.dataset.theme); } catch (e) {}
438
+ label();
439
+ });
440
+ label();
441
+
442
+ const tip = document.getElementById('tooltip');
443
+ const fmt = (n) => n.toLocaleString();
444
+ const show = (html, x, y) => {
445
+ tip.innerHTML = html; tip.hidden = false;
446
+ const r = tip.getBoundingClientRect();
447
+ tip.style.left = Math.min(x + 14, innerWidth - r.width - 8) + 'px';
448
+ tip.style.top = Math.max(8, y - r.height - 10) + 'px';
449
+ };
450
+ const hide = () => { tip.hidden = true; };
451
+ const esc = (s) => s.replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
452
+
453
+ document.querySelectorAll('[data-tip]').forEach((el) => {
454
+ const at = (e) => show(esc(el.dataset.tip), e.clientX, e.clientY);
455
+ el.addEventListener('mousemove', at);
456
+ el.addEventListener('mouseleave', hide);
457
+ el.addEventListener('focus', () => { const r = el.getBoundingClientRect(); show(esc(el.dataset.tip), r.left, r.top); });
458
+ el.addEventListener('blur', hide);
459
+ });
460
+
461
+ document.querySelectorAll('.linechart').forEach((chart) => {
462
+ const data = JSON.parse(chart.dataset.chart);
463
+ const plot = chart.querySelector('.plot');
464
+ const cross = chart.querySelector('.crosshair');
465
+ const dots = data.series.map((s) => {
466
+ const d = document.createElement('div');
467
+ d.className = 'dot'; d.hidden = true; d.style.background = `var(${s.var})`;
468
+ plot.appendChild(d); return d;
469
+ });
470
+ const n = data.days.length;
471
+ let idx = n - 1;
472
+ const render = () => {
473
+ const r = plot.getBoundingClientRect();
474
+ const x = n > 1 ? idx / (n - 1) : 0;
475
+ cross.hidden = false; cross.style.left = (x * 100) + '%';
476
+ data.series.forEach((s, i) => {
477
+ dots[i].hidden = false;
478
+ dots[i].style.left = (x * 100) + '%';
479
+ dots[i].style.top = ((1 - s.values[idx] / data.top) * 100) + '%';
480
+ });
481
+ const rows = data.series.map((s) =>
482
+ `<div class="tt-row"><span class="key-line" style="background:var(${s.var})"></span>${s.name}` +
483
+ `<span class="tt-val">${fmt(s.values[idx])}</span></div>`).join('');
484
+ const day = new Date(data.days[idx] + 'T00:00:00Z').toLocaleDateString(undefined,
485
+ { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' });
486
+ show(`<div class="muted">${day}</div>${rows}`, r.left + x * r.width, r.top + 20);
487
+ };
488
+ const clear = () => { cross.hidden = true; dots.forEach((d) => d.hidden = true); hide(); };
489
+ plot.addEventListener('mousemove', (e) => {
490
+ const r = plot.getBoundingClientRect();
491
+ idx = Math.max(0, Math.min(n - 1, Math.round((e.clientX - r.left) / r.width * (n - 1))));
492
+ render();
493
+ });
494
+ plot.addEventListener('mouseleave', clear);
495
+ plot.addEventListener('focus', render);
496
+ plot.addEventListener('blur', clear);
497
+ plot.addEventListener('keydown', (e) => {
498
+ if (e.key === 'ArrowLeft') { idx = Math.max(0, idx - 1); render(); e.preventDefault(); }
499
+ if (e.key === 'ArrowRight') { idx = Math.min(n - 1, idx + 1); render(); e.preventDefault(); }
500
+ });
501
+ });
502
+
503
+ const table = document.querySelector('#repos table');
504
+ const body = table.tBodies[0];
505
+ table.querySelectorAll('th').forEach((th, col) => {
506
+ th.addEventListener('click', () => {
507
+ const asc = th.getAttribute('aria-sort') === 'descending';
508
+ table.querySelectorAll('th').forEach((h) => h.removeAttribute('aria-sort'));
509
+ th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
510
+ const numeric = th.classList.contains('num');
511
+ const key = (row) => row.cells[col].dataset.sort ?? row.cells[col].textContent;
512
+ const rows = [...body.rows].sort((a, b) => {
513
+ const [x, y] = [key(a), key(b)];
514
+ const cmp = numeric ? Number(x) - Number(y) : x.localeCompare(y);
515
+ return asc ? cmp : -cmp;
516
+ });
517
+ rows.forEach((r) => body.appendChild(r));
518
+ });
519
+ });
520
+ document.querySelector('.search').addEventListener('input', (e) => {
521
+ const q = e.target.value.toLowerCase();
522
+ [...body.rows].forEach((r) => { r.hidden = q && !r.textContent.toLowerCase().includes(q); });
523
+ });
524
+ })();
525
+ """
526
+
527
+ TEMPLATE = """<!doctype html>
528
+ <html lang="en">
529
+ <head>
530
+ <meta charset="utf-8" />
531
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
532
+ <title>{title}</title>
533
+ <style>{css}</style>
534
+ </head>
535
+ <body>
536
+ <div class="wrap-page">
537
+ <header>
538
+ {avatar}
539
+ <div>
540
+ <h1><a href="{owner_url}" target="_blank" rel="noopener">{display}</a></h1>
541
+ <div class="sub">@{owner} · {repo_count} repositories · traffic {window} · generated {generated}</div>
542
+ </div>
543
+ <button class="theme-toggle" type="button">Dark mode</button>
544
+ </header>
545
+
546
+ <section class="overview">
547
+ <div class="card">
548
+ <div class="hero-label">Views, last 14 days</div>
549
+ <div class="hero-value" title="{hero_full}">{hero}</div>
550
+ <div class="card-sub">across {traffic_count} of {repo_count} repositories with traffic access</div>
551
+ </div>
552
+ <div class="tiles">{tiles}</div>
553
+ </section>
554
+
555
+ <section class="card">
556
+ <h2>Daily traffic</h2>
557
+ <div class="card-sub">Views and clones per day, summed across repositories</div>
558
+ {line}
559
+ </section>
560
+
561
+ <div class="grid-2">
562
+ <section class="card">
563
+ <h2>Most viewed</h2>
564
+ <div class="card-sub">Views in the last 14 days</div>
565
+ {top_views}
566
+ </section>
567
+ <section class="card">
568
+ <h2>Most starred</h2>
569
+ <div class="card-sub">All-time stargazers</div>
570
+ {top_stars}
571
+ </section>
572
+ </div>
573
+
574
+ <div class="grid-2">
575
+ <section class="card">
576
+ <h2>Languages</h2>
577
+ <div class="card-sub">Repositories by primary language</div>
578
+ {languages}
579
+ </section>
580
+ <section class="card">
581
+ <h2>Top referrers</h2>
582
+ <div class="card-sub">Views from each source, summed across repositories</div>
583
+ {referrers}
584
+ </section>
585
+ </div>
586
+
587
+ <section class="card full">
588
+ <h2>Release downloads</h2>
589
+ <div class="card-sub">All-time downloads of uploaded release assets (not Homebrew installs)</div>
590
+ {downloads}
591
+ </section>
592
+
593
+ <section class="card full" id="repos">
594
+ <div class="table-head">
595
+ <div><h2>All repositories</h2><div class="card-sub" style="margin:0">Click a column to sort</div></div>
596
+ <input class="search" type="search" placeholder="Filter repositories…" aria-label="Filter repositories" />
597
+ </div>
598
+ <div class="table-scroll">
599
+ <table>
600
+ <thead><tr>
601
+ <th>Repository</th><th>Language</th><th class="num">Stars</th><th class="num">Forks</th>
602
+ <th class="num">Issues</th><th class="num" aria-sort="descending">Views</th>
603
+ <th class="num">Unique</th><th class="num">Clones</th><th class="num">Downloads</th>
604
+ <th>14-day views</th><th>Last push</th>
605
+ </tr></thead>
606
+ <tbody>{rows}</tbody>
607
+ </table>
608
+ </div>
609
+ </section>
610
+
611
+ {missing}
612
+
613
+ <footer>
614
+ Generated by GitPulse. GitHub reports aggregate traffic for roughly the last 14 days and never identifies
615
+ individual visitors. Unique counts are summed per repository and per day, so one person can be counted
616
+ more than once.
617
+ </footer>
618
+ </div>
619
+ <div id="tooltip" role="tooltip" hidden></div>
620
+ <script>{js}</script>
621
+ </body>
622
+ </html>
623
+ """