Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit534afddunknown_key

feat(insights): Developer Velocity Dashboard at /:owner/:repo/insights/velocity

feat(insights): Developer Velocity Dashboard at /:owner/:repo/insights/velocity

Adds a new server-rendered insights page showing PR throughput, contributor
activity, and merge-speed analytics for any repo — zero new DB tables, all
derived from pull_requests, pr_comments, and users.

Panels:
- 4 team summary cards (PRs opened, merged, avg time-to-merge, top reviewer)
- PR merge speed buckets (Fast <4h, Normal 4-48h, Slow >48h)
- Top contributors table with avatar initials, PRs opened/merged, avg TTM,
  and human review count

Supports ?window=7|30|90 day filters. All queries run in parallel via
Promise.all. Middleware follows the path-scoped softAuth rule (no use("*", …)).
CSS scoped under .vel-* prefix. Links to DORA via an insights sub-nav.

bun tsc --noEmit: 0 errors.

https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
Claude committed on May 28, 2026Parent: a991c92
1 file changed+7210534afdd8120dff341252e61839032e74372829dd
1 changed file+721−0
Addedsrc/routes/velocity.tsx+721−0View fileUnifiedSplit
1/**
2 * Developer Velocity Dashboard.
3 *
4 * Route: GET /:owner/:repo/insights/velocity
5 *
6 * Query params: ?window=7|30|90 (default 30 days)
7 *
8 * Panels:
9 * 1. 4 team-summary stat cards (PRs opened, PRs merged, avg time-to-merge,
10 * most active reviewer)
11 * 2. Top contributors table (PRs opened, PRs merged, avg TTM, reviews given)
12 * 3. PR size insights — count by merge speed bucket (Fast <4h, Normal 4-48h,
13 * Slow >48h)
14 *
15 * All queries are scoped to the repository and the chosen time window.
16 * No new DB tables — everything derived from pull_requests + pr_comments + users.
17 */
18
19import { Hono } from "hono";
20import { db } from "../db";
21import {
22 pullRequests,
23 prComments,
24 users,
25 repositories,
26} from "../db/schema";
27import { eq, and, gte, sql, desc } from "drizzle-orm";
28import type { AuthEnv } from "../middleware/auth";
29import { softAuth } from "../middleware/auth";
30import { requireRepoAccess } from "../middleware/repo-access";
31import { Layout } from "../views/layout";
32import { RepoHeader, RepoNav } from "../views/components";
33import { getUnreadCount } from "../lib/unread";
34
35const velocityRoutes = new Hono<AuthEnv>();
36
37// ─── CSS ──────────────────────────────────────────────────────────────────────
38
39const styles = `
40 .vel-wrap {
41 max-width: 1080px;
42 margin: 0 auto;
43 padding: var(--space-5) var(--space-4);
44 }
45
46 /* Insights sub-navigation */
47 .vel-subnav {
48 display: flex;
49 gap: 4px;
50 margin-bottom: var(--space-5);
51 border-bottom: 1px solid var(--border);
52 padding-bottom: 0;
53 }
54 .vel-subnav-link {
55 padding: 8px 14px;
56 font-size: 13px;
57 font-weight: 500;
58 color: var(--text-muted);
59 text-decoration: none;
60 border-bottom: 2px solid transparent;
61 margin-bottom: -1px;
62 transition: color 120ms ease, border-color 120ms ease;
63 border-radius: 4px 4px 0 0;
64 }
65 .vel-subnav-link:hover { color: var(--text); }
66 .vel-subnav-link.active {
67 color: var(--accent, #5865f2);
68 border-bottom-color: var(--accent, #5865f2);
69 }
70
71 /* Hero */
72 .vel-hero {
73 position: relative;
74 margin-bottom: var(--space-5);
75 padding: var(--space-5) var(--space-6);
76 background: var(--bg-elevated);
77 border: 1px solid var(--border);
78 border-radius: 14px;
79 overflow: hidden;
80 }
81 .vel-hero::before {
82 content: '';
83 position: absolute;
84 top: 0; left: 0; right: 0;
85 height: 2px;
86 background: linear-gradient(90deg, transparent 0%, #34d399 30%, #3b82f6 70%, transparent 100%);
87 opacity: 0.8;
88 pointer-events: none;
89 }
90 .vel-hero-title {
91 font-size: 22px;
92 font-weight: 700;
93 margin: 0 0 var(--space-2) 0;
94 color: var(--text);
95 }
96 .vel-hero-sub {
97 color: var(--text-muted);
98 font-size: 14px;
99 margin: 0 0 var(--space-4) 0;
100 }
101
102 /* Window selector */
103 .vel-window-bar {
104 display: flex;
105 gap: 6px;
106 align-items: center;
107 flex-wrap: wrap;
108 }
109 .vel-window-label {
110 font-size: 12px;
111 color: var(--text-muted);
112 margin-right: 4px;
113 }
114 .vel-window-btn {
115 display: inline-block;
116 padding: 4px 12px;
117 border-radius: 6px;
118 font-size: 12px;
119 font-weight: 500;
120 text-decoration: none;
121 border: 1px solid var(--border);
122 color: var(--text-muted);
123 background: var(--bg);
124 transition: border-color 120ms ease, color 120ms ease;
125 }
126 .vel-window-btn:hover { color: var(--text); border-color: var(--border-strong, var(--border)); }
127 .vel-window-btn.active {
128 background: var(--accent, #5865f2);
129 border-color: var(--accent, #5865f2);
130 color: #fff;
131 }
132
133 /* Stat cards row */
134 .vel-cards {
135 display: grid;
136 grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
137 gap: var(--space-4);
138 margin-bottom: var(--space-5);
139 }
140 .vel-card {
141 background: var(--bg-elevated);
142 border: 1px solid var(--border);
143 border-radius: 12px;
144 padding: var(--space-4);
145 display: flex;
146 flex-direction: column;
147 gap: 6px;
148 }
149 .vel-card-label {
150 font-size: 11px;
151 text-transform: uppercase;
152 letter-spacing: 0.08em;
153 color: var(--text-muted);
154 font-weight: 600;
155 }
156 .vel-card-value {
157 font-size: 28px;
158 font-weight: 700;
159 font-variant-numeric: tabular-nums;
160 color: var(--text);
161 line-height: 1.1;
162 }
163 .vel-card-value.vel-na {
164 font-size: 18px;
165 color: var(--text-muted);
166 }
167 .vel-card-hint {
168 font-size: 12px;
169 color: var(--text-muted);
170 line-height: 1.4;
171 }
172
173 /* Section headings */
174 .vel-section-title {
175 font-size: 15px;
176 font-weight: 600;
177 color: var(--text);
178 margin: 0 0 var(--space-3) 0;
179 }
180
181 /* Contributor table */
182 .vel-table-wrap {
183 background: var(--bg-elevated);
184 border: 1px solid var(--border);
185 border-radius: 12px;
186 overflow: hidden;
187 margin-bottom: var(--space-5);
188 }
189 .vel-table {
190 width: 100%;
191 border-collapse: collapse;
192 font-size: 13px;
193 }
194 .vel-table th {
195 text-align: left;
196 padding: 10px 16px;
197 font-size: 11px;
198 text-transform: uppercase;
199 letter-spacing: 0.07em;
200 color: var(--text-muted);
201 border-bottom: 1px solid var(--border);
202 white-space: nowrap;
203 }
204 .vel-table td {
205 padding: 10px 16px;
206 border-bottom: 1px solid var(--border);
207 color: var(--text);
208 vertical-align: middle;
209 font-variant-numeric: tabular-nums;
210 }
211 .vel-table tr:last-child td { border-bottom: none; }
212 .vel-table tr:hover td { background: rgba(255,255,255,0.03); }
213
214 /* Avatar initials */
215 .vel-avatar {
216 display: inline-flex;
217 align-items: center;
218 justify-content: center;
219 width: 28px;
220 height: 28px;
221 border-radius: 50%;
222 background: linear-gradient(135deg, #5865f2, #34d399);
223 color: #fff;
224 font-size: 11px;
225 font-weight: 700;
226 text-transform: uppercase;
227 margin-right: 8px;
228 flex-shrink: 0;
229 }
230 .vel-user-cell {
231 display: flex;
232 align-items: center;
233 }
234 .vel-username {
235 font-weight: 500;
236 color: var(--text);
237 }
238 .vel-display-name {
239 font-size: 11px;
240 color: var(--text-muted);
241 margin-top: 1px;
242 }
243
244 /* Size buckets */
245 .vel-buckets {
246 display: grid;
247 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
248 gap: var(--space-4);
249 margin-bottom: var(--space-5);
250 }
251 .vel-bucket {
252 background: var(--bg-elevated);
253 border: 1px solid var(--border);
254 border-radius: 12px;
255 padding: var(--space-4);
256 display: flex;
257 flex-direction: column;
258 gap: 6px;
259 }
260 .vel-bucket-label {
261 font-size: 12px;
262 font-weight: 600;
263 color: var(--text-muted);
264 }
265 .vel-bucket-value {
266 font-size: 32px;
267 font-weight: 700;
268 font-variant-numeric: tabular-nums;
269 line-height: 1;
270 }
271 .vel-bucket-value.fast { color: #34d399; }
272 .vel-bucket-value.normal { color: #60a5fa; }
273 .vel-bucket-value.slow { color: #f87171; }
274 .vel-bucket-hint {
275 font-size: 11px;
276 color: var(--text-muted);
277 }
278
279 /* Empty state */
280 .vel-empty {
281 text-align: center;
282 padding: var(--space-6) var(--space-4);
283 border: 1px dashed var(--border);
284 border-radius: 12px;
285 color: var(--text-muted);
286 margin-bottom: var(--space-5);
287 }
288 .vel-empty strong {
289 display: block;
290 font-size: 15px;
291 color: var(--text);
292 margin-bottom: 6px;
293 }
294 .vel-empty span { font-size: 13px; }
295
296 /* Numeric right-align for stat columns */
297 .vel-num { text-align: right; }
298 .vel-table th.vel-num { text-align: right; }
299`;
300
301// ─── Helpers ──────────────────────────────────────────────────────────────────
302
303function formatHours(h: number): string {
304 if (h < 1) return `${Math.round(h * 60)} min`;
305 if (h < 24) return `${h.toFixed(1)} h`;
306 return `${(h / 24).toFixed(1)} d`;
307}
308
309// ─── Route ────────────────────────────────────────────────────────────────────
310
311velocityRoutes.use(
312 "/:owner/:repo/insights/velocity",
313 softAuth
314);
315
316velocityRoutes.get(
317 "/:owner/:repo/insights/velocity",
318 requireRepoAccess("read"),
319 async (c) => {
320 const { owner, repo } = c.req.param();
321 const user = c.get("user") ?? null;
322 const repository = (
323 c.get("repository" as never) as { id: string; name: string; isPrivate: boolean }
324 ) ?? null;
325
326 if (!repository) {
327 return c.html("Repository not found", 404);
328 }
329
330 // Parse window (days)
331 const windowParam = c.req.query("window");
332 const windowDays =
333 windowParam === "7" ? 7 : windowParam === "90" ? 90 : 30;
334 const windowStart = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
335 const repoId = repository.id;
336
337 // ─── Parallel DB queries ───────────────────────────────────────────────
338
339 const [contributorRows, summaryRows, reviewerRows] = await Promise.all([
340
341 // Query 1: per-author PR stats + reviews given
342 (async () => {
343 try {
344 // Subquery: reviews given per author (pr_comments where !isAiReview,
345 // joined via pull_requests to scope to this repo + window)
346 const reviewsSubq = db
347 .select({
348 authorId: prComments.authorId,
349 reviews: sql<number>`count(*)::int`.as("reviews"),
350 })
351 .from(prComments)
352 .innerJoin(
353 pullRequests,
354 eq(prComments.pullRequestId, pullRequests.id)
355 )
356 .where(
357 and(
358 eq(pullRequests.repositoryId, repoId),
359 gte(pullRequests.createdAt, windowStart),
360 eq(prComments.isAiReview, false)
361 )
362 )
363 .groupBy(prComments.authorId)
364 .as("reviews_by_author");
365
366 const rows = await db
367 .select({
368 authorId: pullRequests.authorId,
369 username: users.username,
370 displayName: users.displayName,
371 prsOpened: sql<number>`count(*)::int`,
372 prsMerged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
373 avgTtmHours: sql<number>`
374 avg(
375 case when ${pullRequests.state} = 'merged' and ${pullRequests.mergedAt} is not null
376 then extract(epoch from (${pullRequests.mergedAt} - ${pullRequests.createdAt})) / 3600.0
377 end
378 )
379 `,
380 reviewsGiven: sql<number>`coalesce(${reviewsSubq.reviews}, 0)::int`,
381 })
382 .from(pullRequests)
383 .innerJoin(users, eq(pullRequests.authorId, users.id))
384 .leftJoin(
385 reviewsSubq,
386 eq(pullRequests.authorId, reviewsSubq.authorId)
387 )
388 .where(
389 and(
390 eq(pullRequests.repositoryId, repoId),
391 gte(pullRequests.createdAt, windowStart)
392 )
393 )
394 .groupBy(
395 pullRequests.authorId,
396 users.username,
397 users.displayName,
398 reviewsSubq.reviews
399 )
400 .orderBy(desc(sql`count(*)`));
401
402 return rows;
403 } catch (err) {
404 console.warn("[velocity] contributor query failed:", err);
405 return null;
406 }
407 })(),
408
409 // Query 2: team summary aggregates (total PRs opened, merged, avg TTM)
410 (async () => {
411 try {
412 const [row] = await db
413 .select({
414 totalOpened: sql<number>`count(*)::int`,
415 totalMerged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
416 avgTtmHours: sql<number>`
417 avg(
418 case when ${pullRequests.state} = 'merged' and ${pullRequests.mergedAt} is not null
419 then extract(epoch from (${pullRequests.mergedAt} - ${pullRequests.createdAt})) / 3600.0
420 end
421 )
422 `,
423 })
424 .from(pullRequests)
425 .where(
426 and(
427 eq(pullRequests.repositoryId, repoId),
428 gte(pullRequests.createdAt, windowStart)
429 )
430 );
431 return row ?? null;
432 } catch (err) {
433 console.warn("[velocity] summary query failed:", err);
434 return null;
435 }
436 })(),
437
438 // Query 3: most active reviewer (by pr_comment count, human only)
439 (async () => {
440 try {
441 const rows = await db
442 .select({
443 username: users.username,
444 reviewCount: sql<number>`count(*)::int`,
445 })
446 .from(prComments)
447 .innerJoin(users, eq(prComments.authorId, users.id))
448 .innerJoin(
449 pullRequests,
450 eq(prComments.pullRequestId, pullRequests.id)
451 )
452 .where(
453 and(
454 eq(pullRequests.repositoryId, repoId),
455 gte(pullRequests.createdAt, windowStart),
456 eq(prComments.isAiReview, false)
457 )
458 )
459 .groupBy(users.username)
460 .orderBy(desc(sql`count(*)`))
461 .limit(1);
462 return rows[0] ?? null;
463 } catch (err) {
464 console.warn("[velocity] reviewer query failed:", err);
465 return null;
466 }
467 })(),
468 ]);
469
470 // ─── Derived values ────────────────────────────────────────────────────
471
472 const totalOpened = summaryRows?.totalOpened ?? 0;
473 const totalMerged = summaryRows?.totalMerged ?? 0;
474 const avgTtmRaw =
475 summaryRows?.avgTtmHours != null
476 ? Number(summaryRows.avgTtmHours)
477 : null;
478 const avgTtm = avgTtmRaw != null && !isNaN(avgTtmRaw) ? avgTtmRaw : null;
479 const topReviewer = reviewerRows?.username ?? null;
480
481 const contributors = contributorRows ?? [];
482
483 // PR age buckets derived from contributor data
484 // We need per-PR TTM to bucket — run a targeted query
485 let fastCount = 0;
486 let normalCount = 0;
487 let slowCount = 0;
488
489 try {
490 const bucketRows = await db
491 .select({
492 ttmHours: sql<number>`
493 extract(epoch from (${pullRequests.mergedAt} - ${pullRequests.createdAt})) / 3600.0
494 `,
495 })
496 .from(pullRequests)
497 .where(
498 and(
499 eq(pullRequests.repositoryId, repoId),
500 gte(pullRequests.createdAt, windowStart),
501 eq(pullRequests.state, "merged"),
502 sql`${pullRequests.mergedAt} is not null`
503 )
504 );
505
506 for (const row of bucketRows) {
507 const h = Number(row.ttmHours);
508 if (isNaN(h)) continue;
509 if (h < 4) fastCount++;
510 else if (h <= 48) normalCount++;
511 else slowCount++;
512 }
513 } catch (err) {
514 console.warn("[velocity] bucket query failed:", err);
515 }
516
517 const noPrs = totalOpened === 0 && contributors.length === 0;
518
519 // Unread notification count for the nav badge
520 const unreadCount = user ? await getUnreadCount(user.id) : 0;
521
522 // ─── Render ───────────────────────────────────────────────────────────
523
524 const baseUrl = `/${owner}/${repo}/insights/velocity`;
525
526 return c.html(
527 <Layout
528 title={`Velocity — ${owner}/${repo}`}
529 user={user}
530 notificationCount={unreadCount}
531 >
532 <style dangerouslySetInnerHTML={{ __html: styles }} />
533 <div class="vel-wrap">
534 <RepoHeader owner={owner} repo={repo} />
535 <RepoNav owner={owner} repo={repo} active="insights" />
536
537 {/* Insights sub-nav */}
538 <div class="vel-subnav">
539 <a
540 href={`/${owner}/${repo}/insights/dora`}
541 class="vel-subnav-link"
542 >
543 DORA
544 </a>
545 <a
546 href={`/${owner}/${repo}/insights/velocity`}
547 class="vel-subnav-link active"
548 >
549 Velocity
550 </a>
551 </div>
552
553 {/* Hero */}
554 <div class="vel-hero">
555 <h1 class="vel-hero-title">Developer Velocity</h1>
556 <p class="vel-hero-sub">
557 Pull request throughput, contributor activity, and merge speed
558 insights for {owner}/{repo}.
559 </p>
560
561 {/* Window selector */}
562 <div class="vel-window-bar">
563 <span class="vel-window-label">Time window:</span>
564 {([7, 30, 90] as const).map((w) => (
565 <a
566 href={`${baseUrl}?window=${w}`}
567 class={`vel-window-btn${windowDays === w ? " active" : ""}`}
568 >
569 {w}d
570 </a>
571 ))}
572 </div>
573 </div>
574
575 {noPrs ? (
576 <div class="vel-empty">
577 <strong>No pull requests in the last {windowDays} days</strong>
578 <span>
579 Open and merge some PRs, then come back to see velocity metrics.
580 </span>
581 </div>
582 ) : (
583 <>
584 {/* ── Team summary cards ── */}
585 <h2 class="vel-section-title">Team Summary</h2>
586 <div class="vel-cards">
587 <div class="vel-card">
588 <div class="vel-card-label">PRs Opened</div>
589 <div class="vel-card-value">{totalOpened}</div>
590 <div class="vel-card-hint">Last {windowDays} days</div>
591 </div>
592
593 <div class="vel-card">
594 <div class="vel-card-label">PRs Merged</div>
595 <div class="vel-card-value">{totalMerged}</div>
596 <div class="vel-card-hint">
597 {totalOpened > 0
598 ? `${Math.round((totalMerged / totalOpened) * 100)}% merge rate`
599 : "—"}
600 </div>
601 </div>
602
603 <div class="vel-card">
604 <div class="vel-card-label">Avg Time to Merge</div>
605 {avgTtm !== null ? (
606 <>
607 <div class="vel-card-value">{formatHours(avgTtm)}</div>
608 <div class="vel-card-hint">Across merged PRs</div>
609 </>
610 ) : (
611 <div class="vel-card-value vel-na">No merged PRs</div>
612 )}
613 </div>
614
615 <div class="vel-card">
616 <div class="vel-card-label">Top Reviewer</div>
617 {topReviewer ? (
618 <>
619 <div class="vel-card-value" style="font-size:18px">
620 {topReviewer}
621 </div>
622 <div class="vel-card-hint">Most PR comments</div>
623 </>
624 ) : (
625 <div class="vel-card-value vel-na">No reviews</div>
626 )}
627 </div>
628 </div>
629
630 {/* ── Merge speed buckets ── */}
631 <h2 class="vel-section-title">PR Merge Speed</h2>
632 <div class="vel-buckets">
633 <div class="vel-bucket">
634 <div class="vel-bucket-label">Fast</div>
635 <div class="vel-bucket-value fast">{fastCount}</div>
636 <div class="vel-bucket-hint">Merged in under 4 hours</div>
637 </div>
638 <div class="vel-bucket">
639 <div class="vel-bucket-label">Normal</div>
640 <div class="vel-bucket-value normal">{normalCount}</div>
641 <div class="vel-bucket-hint">Merged in 4 – 48 hours</div>
642 </div>
643 <div class="vel-bucket">
644 <div class="vel-bucket-label">Slow</div>
645 <div class="vel-bucket-value slow">{slowCount}</div>
646 <div class="vel-bucket-hint">Merged in over 48 hours</div>
647 </div>
648 </div>
649
650 {/* ── Top contributors table ── */}
651 <h2 class="vel-section-title">Top Contributors</h2>
652 {contributors.length === 0 ? (
653 <div class="vel-empty">
654 <strong>No contributors found</strong>
655 <span>No PRs were opened in this window.</span>
656 </div>
657 ) : (
658 <div class="vel-table-wrap">
659 <table class="vel-table">
660 <thead>
661 <tr>
662 <th>Contributor</th>
663 <th class="vel-num">PRs Opened</th>
664 <th class="vel-num">PRs Merged</th>
665 <th class="vel-num">Avg Time to Merge</th>
666 <th class="vel-num">Reviews Given</th>
667 </tr>
668 </thead>
669 <tbody>
670 {contributors.map((c) => {
671 const initial = (c.username ?? "?")[0];
672 const ttmH =
673 c.avgTtmHours != null
674 ? Number(c.avgTtmHours)
675 : null;
676 const ttmDisplay =
677 ttmH != null && !isNaN(ttmH)
678 ? formatHours(ttmH)
679 : "—";
680 return (
681 <tr key={c.authorId}>
682 <td>
683 <div class="vel-user-cell">
684 <span class="vel-avatar">{initial}</span>
685 <div>
686 <div class="vel-username">
687 <a
688 href={`/${c.username}`}
689 style="color:inherit;text-decoration:none"
690 >
691 {c.username}
692 </a>
693 </div>
694 {c.displayName && (
695 <div class="vel-display-name">
696 {c.displayName}
697 </div>
698 )}
699 </div>
700 </div>
701 </td>
702 <td class="vel-num">{c.prsOpened}</td>
703 <td class="vel-num">{c.prsMerged}</td>
704 <td class="vel-num">{ttmDisplay}</td>
705 <td class="vel-num">{c.reviewsGiven}</td>
706 </tr>
707 );
708 })}
709 </tbody>
710 </table>
711 </div>
712 )}
713 </>
714 )}
715 </div>
716 </Layout>
717 );
718 }
719);
720
721export default velocityRoutes;
0722