CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
velocity.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.
| 534afdd | 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 | ||
| 19 | import { Hono } from "hono"; | |
| 20 | import { db } from "../db"; | |
| 21 | import { | |
| 22 | pullRequests, | |
| 23 | prComments, | |
| 24 | users, | |
| 25 | repositories, | |
| 26 | } from "../db/schema"; | |
| 27 | import { eq, and, gte, sql, desc } from "drizzle-orm"; | |
| 28 | import type { AuthEnv } from "../middleware/auth"; | |
| 29 | import { softAuth } from "../middleware/auth"; | |
| 30 | import { requireRepoAccess } from "../middleware/repo-access"; | |
| 31 | import { Layout } from "../views/layout"; | |
| 32 | import { RepoHeader, RepoNav } from "../views/components"; | |
| 33 | import { getUnreadCount } from "../lib/unread"; | |
| 34 | ||
| 35 | const velocityRoutes = new Hono<AuthEnv>(); | |
| 36 | ||
| 37 | // ─── CSS ────────────────────────────────────────────────────────────────────── | |
| 38 | ||
| 39 | const 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 | ||
| 303 | function 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 | ||
| 311 | velocityRoutes.use( | |
| 312 | "/:owner/:repo/insights/velocity", | |
| 313 | softAuth | |
| 314 | ); | |
| 315 | ||
| 316 | velocityRoutes.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 | ||
| 721 | export default velocityRoutes; |