Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

traffic.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

traffic.tsxBlame644 lines · 1 contributor
8f50ed0Claude1/**
2 * Block F1 — Traffic analytics UI.
3 *
4 * GET /:owner/:repo/traffic — owner-only 14-day views/clones chart,
5 * unique visitors, top paths + referers.
f0b5874Claude6 *
7 * 2026 polish: gradient-hairline hero + radial orb + stat-card grid with
8 * tabular-nums + ▲/▼ trend arrows, simple 14-day bar chart, top-referers
9 * list with mono URLs, dashed-orb empty states. Every class prefixed
10 * `.traffic-` so this surface can't bleed into the wider repo polish. All
11 * routes, queries, and the `summarise()` contract preserved exactly.
8f50ed0Claude12 */
13
14import { Hono } from "hono";
15import { and, eq } from "drizzle-orm";
16import { db } from "../db";
17import { repositories, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader, RepoNav } from "../views/components";
20import { softAuth, requireAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { summarise } from "../lib/traffic";
23
24const traffic = new Hono<AuthEnv>();
25traffic.use("*", softAuth);
26
f0b5874Claude27/* ─────────────────────────────────────────────────────────────────────────
28 * Scoped CSS — every class prefixed `.traffic-` so this surface can't
29 * bleed into the wider repo polish. Mirrors the gradient-hairline hero +
30 * stat-card grid pattern from `insights.tsx` + `admin-integrations`.
31 * ───────────────────────────────────────────────────────────────────── */
32const styles = `
eed4684Claude33 .traffic-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
f0b5874Claude34
35 .traffic-hero {
36 position: relative;
37 margin-bottom: var(--space-5);
38 padding: var(--space-5) var(--space-6);
39 background: var(--bg-elevated);
40 border: 1px solid var(--border);
41 border-radius: 16px;
42 overflow: hidden;
43 }
44 .traffic-hero::before {
45 content: '';
46 position: absolute;
47 top: 0; left: 0; right: 0;
48 height: 2px;
49 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
50 opacity: 0.75;
51 pointer-events: none;
52 }
53 .traffic-hero-orb {
54 position: absolute;
55 inset: -30% -15% auto auto;
56 width: 460px; height: 460px;
57 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
58 filter: blur(80px);
59 opacity: 0.75;
60 pointer-events: none;
61 z-index: 0;
62 }
63 .traffic-hero-inner {
64 position: relative;
65 z-index: 1;
66 display: flex;
67 align-items: flex-start;
68 justify-content: space-between;
69 gap: var(--space-4);
70 flex-wrap: wrap;
71 }
72 .traffic-hero-text { max-width: 720px; }
73 .traffic-eyebrow {
74 display: inline-flex;
75 align-items: center;
76 gap: 8px;
77 text-transform: uppercase;
78 font-family: var(--font-mono);
79 font-size: 11px;
80 letter-spacing: 0.18em;
81 color: var(--text-muted);
82 font-weight: 600;
83 margin-bottom: 14px;
84 }
85 .traffic-eyebrow-dot {
86 width: 8px; height: 8px;
87 border-radius: 9999px;
88 background: linear-gradient(135deg, #8c6dff, #36c5d6);
89 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
90 }
91 .traffic-title {
92 font-family: var(--font-display);
93 font-size: clamp(28px, 4vw, 40px);
94 font-weight: 800;
95 letter-spacing: -0.028em;
96 line-height: 1.05;
97 margin: 0 0 var(--space-2);
98 color: var(--text-strong);
99 }
100 .traffic-title-grad {
101 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
102 -webkit-background-clip: text;
103 background-clip: text;
104 -webkit-text-fill-color: transparent;
105 color: transparent;
106 }
107 .traffic-sub {
108 font-size: 15px;
109 color: var(--text-muted);
110 margin: 0;
111 line-height: 1.55;
112 }
113
114 /* Window switcher (7/14/30/90d) */
115 .traffic-windows {
116 display: inline-flex;
117 gap: 6px;
118 flex-wrap: wrap;
119 align-items: center;
120 }
121 .traffic-window {
122 display: inline-flex;
123 align-items: center;
124 padding: 6px 12px;
125 border-radius: 9999px;
126 border: 1px solid var(--border);
127 background: var(--bg-elevated);
128 color: var(--text-muted);
129 font-size: 12.5px;
130 font-weight: 600;
131 text-decoration: none;
132 font-variant-numeric: tabular-nums;
133 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
134 }
135 .traffic-window:hover { border-color: rgba(140,109,255,0.45); color: var(--text-strong); text-decoration: none; }
136 .traffic-window.is-active {
137 color: #fff;
138 background: linear-gradient(135deg, rgba(140,109,255,0.85), rgba(54,197,214,0.85));
139 border-color: rgba(140,109,255,0.55);
140 }
141
142 /* Stat-card grid */
143 .traffic-stats {
144 display: grid;
145 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
146 gap: var(--space-3);
147 margin-bottom: var(--space-5);
148 }
149 .traffic-stat {
150 position: relative;
151 background: var(--bg-elevated);
152 border: 1px solid var(--border);
153 border-radius: 14px;
154 padding: var(--space-4);
155 transition: border-color 120ms ease, transform 120ms ease;
156 }
157 .traffic-stat:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
158 .traffic-stat-label {
159 font-size: 10.5px;
160 letter-spacing: 0.14em;
161 text-transform: uppercase;
162 color: var(--text-muted);
163 font-weight: 700;
164 margin-bottom: 6px;
165 }
166 .traffic-stat-value {
167 font-family: var(--font-display);
168 font-size: 32px;
169 font-weight: 800;
170 letter-spacing: -0.022em;
171 color: var(--text-strong);
172 font-variant-numeric: tabular-nums;
173 line-height: 1;
174 }
175 .traffic-stat-trend {
176 margin-top: 8px;
177 display: inline-flex;
178 align-items: center;
179 gap: 4px;
180 font-size: 12px;
181 font-variant-numeric: tabular-nums;
182 color: var(--text-muted);
183 }
184 .traffic-stat-trend.is-up { color: #6ee7b7; }
185 .traffic-stat-trend.is-down { color: #fca5a5; }
186 .traffic-stat-trend .arrow { font-size: 11px; line-height: 1; }
187 .traffic-stat-hint {
188 margin-top: 6px;
189 font-size: 12px;
190 color: var(--text-muted);
191 }
192
193 /* Section heading */
194 .traffic-section-head {
195 margin: 0 0 var(--space-3);
196 display: flex;
197 align-items: baseline;
198 justify-content: space-between;
199 gap: var(--space-3);
200 flex-wrap: wrap;
201 }
202 .traffic-section-title {
203 margin: 0;
204 font-family: var(--font-display);
205 font-size: 18px;
206 font-weight: 700;
207 letter-spacing: -0.018em;
208 color: var(--text-strong);
209 }
210 .traffic-section-sub { font-size: 12.5px; color: var(--text-muted); }
211
212 /* Bar-chart card */
213 .traffic-chart {
214 background: var(--bg-elevated);
215 border: 1px solid var(--border);
216 border-radius: 14px;
217 padding: var(--space-4) var(--space-5);
218 margin-bottom: var(--space-5);
219 }
220 .traffic-chart-bars {
221 display: flex;
222 align-items: flex-end;
223 gap: 6px;
224 height: 160px;
225 padding: 12px 0 10px;
226 border-bottom: 1px solid var(--border);
227 }
228 .traffic-bar {
229 flex: 1;
230 min-width: 8px;
231 position: relative;
232 display: flex;
233 flex-direction: column-reverse;
234 border-radius: 4px 4px 0 0;
235 overflow: hidden;
236 background: rgba(255,255,255,0.03);
237 transition: transform 120ms ease;
238 }
239 .traffic-bar:hover { transform: translateY(-2px); }
240 .traffic-bar .seg-views {
241 background: linear-gradient(180deg, #8c6dff, #6d4dff);
242 box-shadow: 0 0 12px -2px rgba(140,109,255,0.45);
243 }
244 .traffic-bar .seg-clones {
245 background: linear-gradient(180deg, #36c5d6, #0891b2);
246 }
247 .traffic-bar-day {
248 margin-top: 6px;
249 font-family: var(--font-mono);
250 font-size: 10px;
251 color: var(--text-muted);
252 text-align: center;
253 transform: rotate(-35deg);
254 transform-origin: center;
255 white-space: nowrap;
256 }
257 .traffic-bars-row {
258 display: flex;
259 gap: 6px;
260 margin-top: 12px;
261 }
262 .traffic-bars-row > div { flex: 1; min-width: 8px; }
263 .traffic-chart-legend {
264 display: flex;
265 gap: 18px;
266 margin-top: 16px;
267 font-size: 12px;
268 color: var(--text-muted);
269 }
270 .traffic-legend-dot {
271 display: inline-block;
272 width: 10px; height: 10px;
273 border-radius: 3px;
274 margin-right: 6px;
275 vertical-align: middle;
276 }
277 .traffic-legend-dot.views { background: linear-gradient(180deg, #8c6dff, #6d4dff); }
278 .traffic-legend-dot.clones { background: linear-gradient(180deg, #36c5d6, #0891b2); }
279
280 /* Two-column lists (top paths + referrers) */
281 .traffic-twocol {
282 display: grid;
283 grid-template-columns: 1fr 1fr;
284 gap: var(--space-4);
285 margin-bottom: var(--space-5);
286 }
287 @media (max-width: 720px) {
288 .traffic-twocol { grid-template-columns: 1fr; }
289 }
290 .traffic-list-card {
291 background: var(--bg-elevated);
292 border: 1px solid var(--border);
293 border-radius: 14px;
294 padding: var(--space-3) var(--space-4);
295 }
296 .traffic-list-row {
297 display: flex;
298 align-items: center;
299 justify-content: space-between;
300 gap: 12px;
301 padding: 8px 0;
302 border-bottom: 1px solid var(--border);
303 }
304 .traffic-list-row:last-child { border-bottom: 0; }
305 .traffic-list-key {
306 font-family: var(--font-mono);
307 font-size: 12.5px;
308 color: var(--text);
309 overflow: hidden;
310 text-overflow: ellipsis;
311 white-space: nowrap;
312 flex: 1;
313 min-width: 0;
314 }
315 .traffic-list-key a { color: inherit; text-decoration: none; }
316 .traffic-list-key a:hover { color: var(--accent); text-decoration: none; }
317 .traffic-list-val {
318 font-family: var(--font-mono);
319 font-size: 12.5px;
320 color: var(--text-muted);
321 font-variant-numeric: tabular-nums;
322 white-space: nowrap;
323 }
324
325 /* Empty state — dashed orb card */
326 .traffic-empty {
327 position: relative;
328 overflow: hidden;
329 text-align: center;
330 padding: var(--space-6) var(--space-4);
331 border: 1px dashed var(--border-strong, var(--border));
332 border-radius: 16px;
333 background: rgba(255,255,255,0.012);
334 color: var(--text-muted);
335 }
336 .traffic-empty::before {
337 content: '';
338 position: absolute;
339 inset: -40% -20% auto auto;
340 width: 320px; height: 320px;
341 background: radial-gradient(circle, rgba(140,109,255,0.14), rgba(54,197,214,0.06) 45%, transparent 70%);
342 filter: blur(60px);
343 pointer-events: none;
344 }
345 .traffic-empty-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 10px; }
346 .traffic-empty strong {
347 font-family: var(--font-display);
348 font-size: 17px;
349 font-weight: 700;
350 color: var(--text-strong);
351 margin: 0;
352 }
353 .traffic-empty p { font-size: 13px; margin: 0; max-width: 420px; }
354`;
355
8f50ed0Claude356async function loadRepo(owner: string, repo: string) {
357 try {
358 const [row] = await db
359 .select({
360 id: repositories.id,
361 name: repositories.name,
362 ownerId: repositories.ownerId,
363 starCount: repositories.starCount,
364 forkCount: repositories.forkCount,
365 })
366 .from(repositories)
367 .innerJoin(users, eq(repositories.ownerId, users.id))
368 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
369 .limit(1);
370 return row || null;
371 } catch {
372 return null;
373 }
374}
375
376traffic.get("/:owner/:repo/traffic", requireAuth, async (c) => {
377 const user = c.get("user")!;
378 const { owner, repo } = c.req.param();
379 const repoRow = await loadRepo(owner, repo);
380 if (!repoRow) return c.notFound();
381 if (repoRow.ownerId !== user.id) {
382 return c.redirect(`/${owner}/${repo}`);
383 }
384
385 const windowDays = Math.max(
386 1,
387 Math.min(90, parseInt(c.req.query("days") || "14", 10) || 14)
388 );
389 const summary = await summarise(repoRow.id, windowDays);
390
f0b5874Claude391 // Bar-chart scaling: tallest single-day total across the window.
8f50ed0Claude392 const maxN = Math.max(
393 1,
394 ...summary.daily.map((d) => d.views + d.clones)
395 );
396
f0b5874Claude397 // Crude trend signals: compare first vs second half of the window. Used
398 // only for the ▲/▼ glyph on the stat cards — the actual numbers come
399 // straight from `summarise()` so the contract is preserved.
400 const half = Math.max(1, Math.floor(summary.daily.length / 2));
401 const firstViews = summary.daily
402 .slice(0, half)
403 .reduce((n, d) => n + d.views, 0);
404 const lastViews = summary.daily
405 .slice(half)
406 .reduce((n, d) => n + d.views, 0);
407 const viewsTrend =
408 summary.daily.length < 2
409 ? "flat"
410 : lastViews >= firstViews
411 ? "up"
412 : "down";
413 const firstClones = summary.daily
414 .slice(0, half)
415 .reduce((n, d) => n + d.clones, 0);
416 const lastClones = summary.daily
417 .slice(half)
418 .reduce((n, d) => n + d.clones, 0);
419 const clonesTrend =
420 summary.daily.length < 2
421 ? "flat"
422 : lastClones >= firstClones
423 ? "up"
424 : "down";
425
426 const trendClass = (t: string) =>
427 t === "up" ? "is-up" : t === "down" ? "is-down" : "";
428 const trendArrow = (t: string) => (t === "down" ? "▼" : "▲");
429
8f50ed0Claude430 return c.html(
431 <Layout title={`Traffic — ${owner}/${repo}`} user={user}>
432 <RepoHeader
433 owner={owner}
434 repo={repo}
435 starCount={repoRow.starCount}
436 forkCount={repoRow.forkCount}
437 currentUser={user.username}
438 />
439 <RepoNav owner={owner} repo={repo} active="insights" />
440
f0b5874Claude441 <div class="traffic-wrap">
442 <section class="traffic-hero">
443 <div class="traffic-hero-orb" aria-hidden="true" />
444 <div class="traffic-hero-inner">
445 <div class="traffic-hero-text">
446 <div class="traffic-eyebrow">
447 <span class="traffic-eyebrow-dot" aria-hidden="true" />
448 Traffic · {owner}/{repo}
449 </div>
450 <h2 class="traffic-title">
451 <span class="traffic-title-grad">Who's looking.</span>
452 </h2>
453 <p class="traffic-sub">
454 Views, clones, and unique visitors over the last{" "}
455 {windowDays} day{windowDays === 1 ? "" : "s"} — refreshed live
456 from every web hit and git-http access.
457 </p>
458 </div>
459 <div class="traffic-windows" aria-label="Time window">
460 {[7, 14, 30, 90].map((d) => (
461 <a
462 href={`/${owner}/${repo}/traffic?days=${d}`}
463 class={
464 "traffic-window" + (d === windowDays ? " is-active" : "")
465 }
466 >
467 {d}d
468 </a>
469 ))}
470 </div>
8f50ed0Claude471 </div>
f0b5874Claude472 </section>
473
474 <div class="traffic-stats">
475 <div class="traffic-stat">
476 <div class="traffic-stat-label">Views</div>
477 <div class="traffic-stat-value">
478 {summary.totalViews.toLocaleString()}
479 </div>
480 <div class={"traffic-stat-trend " + trendClass(viewsTrend)}>
481 <span class="arrow" aria-hidden="true">
482 {trendArrow(viewsTrend)}
483 </span>
484 vs prior period
485 </div>
8f50ed0Claude486 </div>
f0b5874Claude487 <div class="traffic-stat">
488 <div class="traffic-stat-label">Unique visitors</div>
489 <div class="traffic-stat-value">
490 {summary.uniqueVisitorsApprox.toLocaleString()}
491 </div>
492 <div class="traffic-stat-hint">Approx · distinct ip-hash</div>
8f50ed0Claude493 </div>
f0b5874Claude494 <div class="traffic-stat">
495 <div class="traffic-stat-label">Clones</div>
496 <div class="traffic-stat-value">
497 {summary.totalClones.toLocaleString()}
498 </div>
499 <div class={"traffic-stat-trend " + trendClass(clonesTrend)}>
500 <span class="arrow" aria-hidden="true">
501 {trendArrow(clonesTrend)}
502 </span>
503 vs prior period
504 </div>
8f50ed0Claude505 </div>
f0b5874Claude506 <div class="traffic-stat">
507 <div class="traffic-stat-label">Referrers</div>
508 <div class="traffic-stat-value">
509 {summary.topReferers.length.toLocaleString()}
510 </div>
511 <div class="traffic-stat-hint">Distinct sources</div>
8f50ed0Claude512 </div>
513 </div>
514
f0b5874Claude515 <div class="traffic-section-head">
516 <h3 class="traffic-section-title">Daily activity</h3>
517 <span class="traffic-section-sub">
518 {summary.daily.length} day{summary.daily.length === 1 ? "" : "s"}{" "}
519 with data
520 </span>
521 </div>
522 <div class="traffic-chart">
523 {summary.daily.length === 0 ? (
524 <div class="traffic-empty">
525 <div class="traffic-empty-inner">
526 <strong>No traffic recorded yet</strong>
527 <p>
528 Views are tracked automatically as people visit this repo;
529 clones + API hits are tracked on git-http access. Share the
530 URL and check back in a bit.
531 </p>
532 </div>
533 </div>
534 ) : (
535 <>
536 <div class="traffic-chart-bars" role="img" aria-label="Daily traffic bars">
537 {summary.daily.map((d) => {
538 const total = d.views + d.clones;
539 const heightPct = Math.max(2, (total / maxN) * 100);
540 const viewsShare = total ? (d.views / total) * heightPct : 0;
541 const clonesShare = total ? (d.clones / total) * heightPct : 0;
542 return (
543 <div
544 class="traffic-bar"
545 title={`${d.day} · ${d.views} views, ${d.clones} clones`}
546 style={`height:${heightPct}%`}
547 >
548 <div class="seg-clones" style={`height:${clonesShare}%`} />
549 <div class="seg-views" style={`height:${viewsShare}%`} />
550 </div>
551 );
552 })}
553 </div>
554 <div class="traffic-bars-row" aria-hidden="true">
555 {summary.daily.map((d) => (
556 <div class="traffic-bar-day">{d.day.slice(5)}</div>
557 ))}
558 </div>
559 <div class="traffic-chart-legend">
560 <span>
561 <span class="traffic-legend-dot views" aria-hidden="true" />
562 Views
8f50ed0Claude563 </span>
f0b5874Claude564 <span>
565 <span class="traffic-legend-dot clones" aria-hidden="true" />
566 Clones
8f50ed0Claude567 </span>
568 </div>
f0b5874Claude569 </>
570 )}
571 </div>
8f50ed0Claude572
f0b5874Claude573 <div class="traffic-twocol">
574 <div>
575 <div class="traffic-section-head">
576 <h3 class="traffic-section-title">Top paths</h3>
577 <span class="traffic-section-sub">
578 {summary.topPaths.length} path
579 {summary.topPaths.length === 1 ? "" : "s"}
580 </span>
581 </div>
582 <div class="traffic-list-card">
583 {summary.topPaths.length === 0 ? (
584 <div class="traffic-empty">
585 <div class="traffic-empty-inner">
586 <strong>No paths recorded</strong>
587 <p>Path hits appear here as visitors browse the repo.</p>
588 </div>
8f50ed0Claude589 </div>
f0b5874Claude590 ) : (
591 summary.topPaths.map((p) => (
592 <div class="traffic-list-row">
593 <span class="traffic-list-key" title={p.path}>
594 {p.path}
595 </span>
596 <span class="traffic-list-val">
597 {p.n.toLocaleString()}
598 </span>
599 </div>
600 ))
601 )}
602 </div>
8f50ed0Claude603 </div>
f0b5874Claude604 <div>
605 <div class="traffic-section-head">
606 <h3 class="traffic-section-title">Top referrers</h3>
607 <span class="traffic-section-sub">
608 {summary.topReferers.length} source
609 {summary.topReferers.length === 1 ? "" : "s"}
610 </span>
611 </div>
612 <div class="traffic-list-card">
613 {summary.topReferers.length === 0 ? (
614 <div class="traffic-empty">
615 <div class="traffic-empty-inner">
616 <strong>No external referrers</strong>
617 <p>
618 Share this repo on social or in docs to start seeing
619 where visits come from.
620 </p>
621 </div>
8f50ed0Claude622 </div>
f0b5874Claude623 ) : (
624 summary.topReferers.map((r) => (
625 <div class="traffic-list-row">
626 <span class="traffic-list-key" title={r.referer}>
627 {r.referer}
628 </span>
629 <span class="traffic-list-val">
630 {r.n.toLocaleString()}
631 </span>
632 </div>
633 ))
634 )}
635 </div>
8f50ed0Claude636 </div>
637 </div>
638 </div>
f0b5874Claude639 <style dangerouslySetInnerHTML={{ __html: styles }} />
8f50ed0Claude640 </Layout>
641 );
642});
643
644export default traffic;