Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

dashboard.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.

dashboard.tsxBlame1497 lines · 4 contributors
f1ab587Claude1/**
2 * Command Center — the live dashboard.
3 *
4 * This is the developer's mission control. One screen that shows:
5 * - All your repos with health scores at a glance
6 * - Live push feed (what just happened, risk scores, repairs)
7 * - CI status for every repo
8 * - Security alerts
9 * - Quick actions (rollback, repair, deploy)
10 *
11 * Plus intelligence settings — toggle auto-repair, scanning, etc.
12 *
13 * GitHub gives you a feed of stars and follows.
14 * gluecron gives you a COMMAND CENTER.
15 */
16
17import { Hono } from "hono";
920812bClaude18import { eq, desc, and, inArray, ne, sql } from "drizzle-orm";
c63b860Claude19import { getCookie, setCookie } from "hono/cookie";
f1ab587Claude20import { db } from "../db";
21import {
22 repositories,
23 users,
24 activityFeed,
25 issues,
26 pullRequests,
27} from "../db/schema";
28import { Layout } from "../views/layout";
febd4f0Claude29import { LiveFeed } from "../views/live-feed";
f1ab587Claude30import { softAuth, requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import {
33 computeHealthScore,
34 detectCIConfig,
35} from "../lib/intelligence";
36import {
37 repoExists,
38 getDefaultBranch,
39 listCommits,
40 listBranches,
41} from "../git/repository";
46d6165Claude42import {
43 computeAiSavingsForUser,
44 computeLifetimeAiSavingsForUser,
45 type AiSavingsReport,
46 type AiSavingsLifetimeReport,
47} from "../lib/ai-hours-saved";
f1ab587Claude48
49const dashboard = new Hono<AuthEnv>();
50
51dashboard.use("*", softAuth);
52
53// ─── COMMAND CENTER ──────────────────────────────────────────
54
55dashboard.get("/dashboard", requireAuth, async (c) => {
56 const user = c.get("user")!;
57
c63b860Claude58 // Block P2 — banner dismiss handler. Set a session cookie and re-redirect
59 // to the bare /dashboard URL so refreshing doesn't keep firing the dismiss.
60 if (c.req.query("p2_dismiss") === "1") {
61 setCookie(c, "p2_verify_dismissed", "1", {
62 path: "/",
63 httpOnly: true,
64 sameSite: "Lax",
65 });
66 return c.redirect("/dashboard");
67 }
68
f1dc7c7Claude69 // ── Loading skeleton (flag-gated) ──
70 // Render an SSR'd structural preview when `?skeleton=1` is present.
71 // Keeps the user oriented on first paint while DB warms up. Behind a
72 // flag until we wire it to streamed/replaced content — we don't ship
73 // a flash before the real markup lands.
74 if (c.req.query("skeleton") === "1") {
75 return c.html(
76 <Layout title="Command Center" user={user}>
77 <DashboardSkeleton />
78 </Layout>
79 );
80 }
81
f1ab587Claude82 // Get all user's repos
83 const repos = await db
84 .select()
85 .from(repositories)
86 .where(eq(repositories.ownerId, user.id))
87 .orderBy(desc(repositories.updatedAt));
88
89 // Compute health scores for all repos (in parallel)
90 const repoData = await Promise.all(
91 repos.map(async (repo) => {
92 let healthScore = 0;
93 let healthGrade = "?" as string;
94 let recentCommits = 0;
95 let branchCount = 0;
96 let ciConfig = null;
97
98 try {
99 if (await repoExists(user.username, repo.name)) {
100 const ref =
101 (await getDefaultBranch(user.username, repo.name)) || "main";
102 const [health, commits, branches, ci] = await Promise.all([
103 computeHealthScore(user.username, repo.name).catch(() => null),
104 listCommits(user.username, repo.name, ref, 5).catch(() => []),
105 listBranches(user.username, repo.name).catch(() => []),
106 detectCIConfig(user.username, repo.name, ref).catch(() => null),
107 ]);
108 if (health) {
109 healthScore = health.score;
110 healthGrade = health.grade;
111 }
112 recentCommits = commits.length;
113 branchCount = branches.length;
114 ciConfig = ci;
115 }
116 } catch {
117 // best effort
118 }
119
120 return {
121 repo,
122 healthScore,
123 healthGrade,
124 recentCommits,
125 branchCount,
126 ciConfig,
127 };
128 })
129 );
130
46d6165Claude131 // Block L9 — AI hours-saved counter. Pull both window + lifetime in
132 // parallel; both helpers swallow DB errors so the dashboard always renders.
133 const [savingsWeek, savingsLifetime] = await Promise.all([
134 computeAiSavingsForUser(user.id, { windowHours: 168 }),
135 computeLifetimeAiSavingsForUser(user.id),
136 ]);
137
f1ab587Claude138 // Get recent activity
139 let recentActivity: Array<{
140 action: string;
141 repoName: string;
142 metadata: string | null;
143 createdAt: Date;
144 }> = [];
145
146 try {
147 const repoIds = repos.map((r) => r.id);
148 if (repoIds.length > 0) {
149 const activity = await db
150 .select({
151 action: activityFeed.action,
152 metadata: activityFeed.metadata,
153 createdAt: activityFeed.createdAt,
154 repoId: activityFeed.repositoryId,
155 })
156 .from(activityFeed)
157 .where(eq(activityFeed.userId, user.id))
158 .orderBy(desc(activityFeed.createdAt))
159 .limit(20);
160
161 recentActivity = activity.map((a) => ({
162 action: a.action,
163 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
164 metadata: a.metadata,
165 createdAt: a.createdAt,
166 }));
167 }
168 } catch {
169 // DB not required for dashboard
170 }
171
920812bClaude172 // Review queue — open non-draft PRs in user's repos, authored by others
173 let reviewQueuePrs: Array<{
174 prNumber: number;
175 prTitle: string;
176 repoName: string;
177 createdAt: Date;
178 }> = [];
179 // Open PRs the user authored that are still open (anywhere on the platform)
180 let myOpenPrs: Array<{
181 prNumber: number;
182 prTitle: string;
183 repoName: string;
184 ownerUsername: string;
185 createdAt: Date;
186 }> = [];
187 try {
188 const repoIds = repos.map((r) => r.id);
189 const prQueries: Promise<any>[] = [];
190 if (repoIds.length > 0) {
191 prQueries.push(
192 db
193 .select({
194 prNumber: pullRequests.number,
195 prTitle: pullRequests.title,
196 repoName: repositories.name,
197 createdAt: pullRequests.createdAt,
198 })
199 .from(pullRequests)
200 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
201 .where(
202 and(
203 inArray(pullRequests.repositoryId, repoIds),
204 eq(pullRequests.state, "open"),
205 ne(pullRequests.authorId, user.id),
206 eq(pullRequests.isDraft, false),
207 )
208 )
209 .orderBy(desc(pullRequests.createdAt))
210 .limit(6)
211 );
212 } else {
213 prQueries.push(Promise.resolve([]));
214 }
215 prQueries.push(
216 db
217 .select({
218 prNumber: pullRequests.number,
219 prTitle: pullRequests.title,
220 repoName: repositories.name,
221 ownerUsername: users.username,
222 createdAt: pullRequests.createdAt,
223 })
224 .from(pullRequests)
225 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
226 .innerJoin(users, eq(repositories.ownerId, users.id))
227 .where(
228 and(
229 eq(pullRequests.authorId, user.id),
230 eq(pullRequests.state, "open"),
231 )
232 )
233 .orderBy(desc(pullRequests.updatedAt))
234 .limit(6)
235 );
236 const [queueRows, myPrRows] = await Promise.all(prQueries);
237 reviewQueuePrs = queueRows;
238 myOpenPrs = myPrRows;
239 } catch { /* non-blocking */ }
240
f1ab587Claude241 const gradeColor = (grade: string) =>
242 grade === "A+" || grade === "A"
243 ? "var(--green)"
244 : grade === "B"
245 ? "#58a6ff"
246 : grade === "C"
247 ? "var(--yellow)"
248 : grade === "?"
249 ? "var(--text-muted)"
250 : "var(--red)";
251
c63b860Claude252 // Block P2 — email verification banner. Shows when the user hasn't
253 // verified yet AND they haven't dismissed it this session. Also surfaces
254 // transient resend feedback (`?verify=sent` / `?verify=rate_limited`)
255 // and the post-register hint (`?welcome=1`).
256 const verifyDismissed = getCookie(c, "p2_verify_dismissed") === "1";
257 const showVerifyBanner =
258 !(user as any).emailVerifiedAt && !verifyDismissed;
259 const verifyQuery = c.req.query("verify");
260 const welcomeQuery = c.req.query("welcome");
261
f1ab587Claude262 return c.html(
263 <Layout title="Command Center" user={user}>
c63b860Claude264 {showVerifyBanner && (
265 <div
dc26881CC LABS App266 style="background: rgba(210, 153, 34, 0.12); border: 1px solid rgba(210, 153, 34, 0.45); color: #e3b341; padding: var(--space-3) var(--space-4); border-radius: 8px; margin-bottom: var(--space-4); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: 14px"
c63b860Claude267 data-p2-verify-banner=""
268 >
269 <div style="flex: 1 1 auto; min-width: 0">
270 {welcomeQuery === "1" ? (
271 <span>
272 Welcome to Gluecron! Check your inbox to verify your email.
273 </span>
274 ) : verifyQuery === "sent" ? (
275 <span>
276 Verification link sent. It may take a minute to arrive.
277 </span>
278 ) : verifyQuery === "rate_limited" ? (
279 <span>
280 You've requested too many verification emails. Try again later.
281 </span>
826eccfTest User282 ) : verifyQuery === "not_configured" ? (
283 <span>
284 Email delivery isn't configured on this instance yet — your
285 site admin needs to set <code>EMAIL_PROVIDER=resend</code> and{" "}
286 <code>RESEND_API_KEY</code>. Until then the verification link
287 is written to the server log.
288 </span>
c63b860Claude289 ) : (
290 <span>Verify your email to keep using Gluecron.</span>
291 )}
292 </div>
293 <form
294 method="post"
295 action="/verify-email/resend"
dc26881CC LABS App296 style="display: inline-flex; gap: var(--space-2); align-items: center; margin: 0"
c63b860Claude297 >
298 <input
299 type="hidden"
300 name="_csrf"
301 value={(c.get("csrfToken") as string | undefined) || ""}
302 />
303 <button
304 type="submit"
305 class="btn"
306 style="padding: 4px 10px; font-size: 12px"
307 >
308 Resend verification link
309 </button>
310 <a
311 href="/dashboard?p2_dismiss=1"
312 class="btn"
313 style="padding: 4px 10px; font-size: 12px"
314 aria-label="Dismiss verification banner"
315 >
316 Dismiss
317 </a>
318 </form>
319 </div>
320 )}
a004c46Claude321 <div class="dash-hero">
322 <div class="dash-hero-bg" aria-hidden="true">
323 <div class="dash-hero-orb" />
f1ab587Claude324 </div>
a004c46Claude325 <div class="dash-hero-inner">
326 <div class="dash-hero-text">
327 <div class="dash-hero-eyebrow">
328 {(() => {
329 const hour = new Date().getHours();
330 if (hour < 5) return "Late night,";
331 if (hour < 12) return "Good morning,";
332 if (hour < 17) return "Good afternoon,";
333 if (hour < 21) return "Good evening,";
334 return "Late night,";
335 })()}{" "}
336 <span class="dash-hero-username">{user.username}</span>
337 </div>
338 <h1 class="dash-hero-title">
339 Your{" "}
340 <span class="gradient-text">command center</span>.
341 </h1>
342 <p class="dash-hero-sub">
343 {repos.length === 0
344 ? "Create your first repository to start shipping with AI."
345 : `${repos.length} repo${repos.length === 1 ? "" : "s"} · real-time health, AI activity, and gate status across everything you own.`}
346 </p>
347 </div>
348 <div class="dash-hero-actions">
349 <a href="/new" class="btn btn-primary">+ New repo</a>
350 <a href="/import" class="btn">Import from GitHub</a>
351 <a href="/settings" class="btn">Settings</a>
352 </div>
f1ab587Claude353 </div>
354 </div>
a004c46Claude355 <style
356 dangerouslySetInnerHTML={{
357 __html: `
358 .dash-hero {
359 position: relative;
360 margin-bottom: var(--space-6);
361 padding: var(--space-5) var(--space-6) var(--space-5);
362 background: var(--bg-elevated);
363 border: 1px solid var(--border);
364 border-radius: 16px;
365 overflow: hidden;
366 }
367 .dash-hero::before {
368 content: '';
369 position: absolute;
370 top: 0; left: 0; right: 0;
371 height: 2px;
372 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
373 opacity: 0.7;
374 pointer-events: none;
375 }
376 .dash-hero-bg {
377 position: absolute;
378 inset: -20% -10% auto auto;
379 width: 380px;
380 height: 380px;
381 pointer-events: none;
382 z-index: 0;
383 }
384 .dash-hero-orb {
385 position: absolute;
386 inset: 0;
387 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
388 filter: blur(80px);
389 opacity: 0.7;
390 animation: dashHeroOrb 14s ease-in-out infinite;
391 }
392 @keyframes dashHeroOrb {
393 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
394 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
395 }
396 @media (prefers-reduced-motion: reduce) {
397 .dash-hero-orb { animation: none; }
398 }
399 .dash-hero-inner {
400 position: relative;
401 z-index: 1;
402 display: flex;
403 justify-content: space-between;
404 align-items: flex-end;
405 gap: var(--space-4);
406 flex-wrap: wrap;
407 }
408 .dash-hero-text { flex: 1; min-width: 280px; }
409 .dash-hero-eyebrow {
410 font-size: 13px;
411 color: var(--text-muted);
412 margin-bottom: var(--space-2);
413 letter-spacing: -0.005em;
414 }
415 .dash-hero-username {
416 color: var(--accent);
417 font-weight: 600;
418 }
419 .dash-hero-title {
420 font-size: clamp(28px, 4vw, 40px);
421 font-family: var(--font-display);
422 font-weight: 800;
423 letter-spacing: -0.028em;
424 line-height: 1.05;
425 margin: 0 0 var(--space-2);
426 color: var(--text-strong);
427 }
428 .dash-hero-title .gradient-text {
429 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
430 -webkit-background-clip: text;
431 background-clip: text;
432 -webkit-text-fill-color: transparent;
433 color: transparent;
434 }
435 .dash-hero-sub {
436 font-size: 15px;
437 color: var(--text-muted);
438 margin: 0;
439 line-height: 1.5;
440 max-width: 580px;
441 }
442 .dash-hero-actions {
443 display: flex;
444 gap: var(--space-2);
445 flex-wrap: wrap;
446 }
447 @media (max-width: 720px) {
448 .dash-hero-inner { flex-direction: column; align-items: flex-start; }
449 .dash-hero-actions { width: 100%; }
f1dc7c7Claude450 .dash-hero-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
451 .dash-hero { padding: var(--space-4); }
452 .dash-hero-text { min-width: 0; }
453 .dash-hero-bg { width: 220px; height: 220px; inset: -10% -20% auto auto; }
454 .ai-hours-saved-tabs { flex-wrap: wrap; }
a004c46Claude455 }
456 `,
457 }}
458 />
f1ab587Claude459
46d6165Claude460 {/* ─── L9: AI hours-saved hero widget ─── */}
461 <AiHoursSavedWidget week={savingsWeek} lifetime={savingsLifetime} />
462
c6018a5Claude463 {/* ─── Quick actions — surfaces the 5 most-leverage AI features so
464 they're discoverable from the dashboard without diving into the
465 AI dropdown in the top nav. Scoped CSS under .qa- prefix. ─── */}
466 <QuickActionsPanel firstRepo={repos[0]} username={user.username} />
467
f1ab587Claude468 {/* ─── Stats Bar ─── */}
dc26881CC LABS App469 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8)">
f1ab587Claude470 <StatBox
471 label="Repositories"
472 value={String(repos.length)}
473 color="var(--text-link)"
474 />
475 <StatBox
476 label="Avg Health"
477 value={
478 repos.length > 0
479 ? String(
480 Math.round(
481 repoData.reduce((s, r) => s + r.healthScore, 0) /
482 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
483 )
484 )
485 : "—"
486 }
487 color="var(--green)"
488 />
489 <StatBox
490 label="Total Stars"
491 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
492 color="var(--yellow)"
493 />
494 <StatBox
495 label="Open Issues"
496 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
497 color="var(--red)"
498 />
499 </div>
500
501 {/* ─── Repo Grid ─── */}
502 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
503 {repos.length === 0 ? (
dc26881CC LABS App504 <div class="empty-state" style="text-align:left;padding:var(--space-6)">
80bed05Claude505 <div style="text-align:center;margin-bottom:20px">
506 <h2 style="margin-bottom:6px">Get started</h2>
507 <p style="color:var(--text-muted);font-size:14px;margin:0">
508 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
509 </p>
510 </div>
511 <div class="panel" style="margin-bottom:20px;text-align:left">
dc26881CC LABS App512 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude513 <div style="flex:1">
514 <div style="font-size:15px;font-weight:600">Create a new repository</div>
515 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
516 Start from scratch with green-ecosystem defaults.
517 </div>
518 </div>
519 <a href="/new" class="btn btn-primary">Create repo</a>
520 </div>
dc26881CC LABS App521 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude522 <div style="flex:1">
523 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
524 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
525 Mirror an existing repo — history, branches, tags.
526 </div>
527 </div>
528 <a href="/import" class="btn">Import repo</a>
529 </div>
dc26881CC LABS App530 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude531 <div style="flex:1">
532 <div style="font-size:15px;font-weight:600">Browse public repos</div>
533 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
534 See what others are building, fork what you like.
535 </div>
536 </div>
537 <a href="/explore" class="btn">Browse</a>
538 </div>
539 </div>
dc26881CC LABS App540 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-4)">
80bed05Claude541 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
542 Push an existing project (preview)
543 </div>
544 <pre style="margin:0;font-size:12px;overflow-x:auto;color:var(--text-muted)"><code># Once you create a repo, you'll see your real clone URL here.
545git remote add gluecron http://localhost:3000/{user.username}/&lt;your-repo&gt;.git
546git push -u gluecron main</code></pre>
547 </div>
f1ab587Claude548 </div>
549 ) : (
dc26881CC LABS App550 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8)">
f1ab587Claude551 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
552 <div class="card" style="padding: 0; overflow: hidden">
553 {/* Health bar at top */}
554 <div
555 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
556 />
dc26881CC LABS App557 <div style="padding: var(--space-4)">
f1ab587Claude558 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
559 <div>
560 <h3 style="font-size: 16px; margin-bottom: 2px">
561 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
562 </h3>
563 {repo.description && (
564 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
565 {repo.description}
566 </p>
567 )}
568 </div>
569 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
570 <div
571 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
572 >
573 {healthGrade}
574 </div>
575 <div style="font-size: 10px; color: var(--text-muted)">
576 {healthScore}/100
577 </div>
578 </div>
579 </div>
580
dc26881CC LABS App581 <div style="display: flex; gap: var(--space-4); font-size: 12px; color: var(--text-muted); margin-top: var(--space-2)">
f1ab587Claude582 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
583 <span>{"\u2606"} {repo.starCount}</span>
584 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
585 </div>
586
587 {ciConfig && ciConfig.commands.length > 0 && (
dc26881CC LABS App588 <div style="margin-top: var(--space-2); display: flex; gap: 6px; flex-wrap: wrap">
f1ab587Claude589 {ciConfig.detected.slice(0, 3).map((d) => (
590 <span
591 class="badge"
592 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
593 >
594 {d}
595 </span>
596 ))}
597 </div>
598 )}
599
dc26881CC LABS App600 <div style="display: flex; gap: 6px; margin-top: var(--space-3)">
f1ab587Claude601 <a
602 href={`/${user.username}/${repo.name}/health`}
603 class="btn btn-sm"
604 style="font-size: 11px; padding: 2px 8px"
605 >
606 Health
607 </a>
608 <a
609 href={`/${user.username}/${repo.name}/dependencies`}
610 class="btn btn-sm"
611 style="font-size: 11px; padding: 2px 8px"
612 >
613 Deps
614 </a>
615 <a
616 href={`/${user.username}/${repo.name}/coupling`}
617 class="btn btn-sm"
618 style="font-size: 11px; padding: 2px 8px"
619 >
620 Insights
621 </a>
622 <a
623 href={`/${user.username}/${repo.name}/settings`}
624 class="btn btn-sm"
625 style="font-size: 11px; padding: 2px 8px"
626 >
627 Settings
628 </a>
629 </div>
630 </div>
631 </div>
632 ))}
633 </div>
634 )}
635
636 {/* ─── Activity Feed ─── */}
637 {recentActivity.length > 0 && (
638 <>
639 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
640 <div class="issue-list">
641 {recentActivity.map((a) => (
642 <div class="issue-item">
dc26881CC LABS App643 <div style="display: flex; gap: var(--space-2); align-items: center">
f1ab587Claude644 <ActivityIcon action={a.action} />
645 <div>
646 <span style="font-size: 14px">
647 {formatAction(a.action)} in{" "}
648 <a href={`/${user.username}/${a.repoName}`}>
649 {a.repoName}
650 </a>
651 </span>
652 <div style="font-size: 12px; color: var(--text-muted)">
653 {formatRelative(a.createdAt)}
654 </div>
655 </div>
656 </div>
657 </div>
658 ))}
659 </div>
660 </>
661 )}
662
7a14837Claude663 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
664 <HealthCoach repoData={repoData} username={user.username} />
665
febd4f0Claude666 {/* ─── Live Activity (SSE) ─── */}
667 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
668
920812bClaude669 {/* ─── Review queue + My open PRs ─── */}
670 {(reviewQueuePrs.length > 0 || myOpenPrs.length > 0) && (
671 <>
672 <style dangerouslySetInnerHTML={{ __html: `
673 .dash-rq { border:1px solid var(--border); border-radius:12px; overflow:hidden; }
674 .dash-rq-head { display:flex; align-items:center; justify-content:space-between; padding:11px 16px; background:var(--bg-elevated); border-bottom:1px solid var(--border); font-size:13px; font-weight:600; }
675 .dash-rq-head-count { font-size:11px; font-weight:700; padding:1px 7px; border-radius:9999px; background:var(--bg-tertiary); color:var(--text-muted); }
676 .dash-rq-row { display:flex; align-items:center; gap:10px; padding:9px 16px; border-bottom:1px solid var(--border); font-size:13px; text-decoration:none; color:inherit; }
677 .dash-rq-row:last-child { border-bottom:none; }
678 .dash-rq-row:hover { background:var(--bg-hover); }
679 .dash-rq-repo { font-size:11px; color:var(--text-muted); flex:0 0 auto; }
680 .dash-rq-title { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
681 .dash-rq-age { font-size:11px; color:var(--text-muted); flex:0 0 auto; }
682 .dash-rq-empty { padding:24px; text-align:center; color:var(--text-muted); font-size:13px; }
683 ` }} />
684 <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:var(--space-4);margin-top:var(--space-8)">
685 {reviewQueuePrs.length > 0 && (
686 <div class="dash-rq">
687 <div class="dash-rq-head">
688 <span>{"⏳"} Needs your review</span>
689 <span class="dash-rq-head-count">{reviewQueuePrs.length}</span>
690 </div>
691 {reviewQueuePrs.map((pr) => (
692 <a
693 href={`/${user.username}/${pr.repoName}/pulls/${pr.prNumber}`}
694 class="dash-rq-row"
695 >
696 <span class="dash-rq-repo">{pr.repoName}</span>
697 <span class="dash-rq-title" title={pr.prTitle}>{pr.prTitle}</span>
698 <span class="dash-rq-age">
699 {formatRelative(pr.createdAt)}
700 </span>
701 </a>
702 ))}
703 </div>
704 )}
705 {myOpenPrs.length > 0 && (
706 <div class="dash-rq">
707 <div class="dash-rq-head">
708 <span>{"○"} Your open PRs</span>
709 <span class="dash-rq-head-count">{myOpenPrs.length}</span>
710 </div>
711 {myOpenPrs.map((pr) => (
712 <a
713 href={`/${pr.ownerUsername}/${pr.repoName}/pulls/${pr.prNumber}`}
714 class="dash-rq-row"
715 >
716 <span class="dash-rq-repo">{pr.repoName}</span>
717 <span class="dash-rq-title" title={pr.prTitle}>{pr.prTitle}</span>
718 <span class="dash-rq-age">
719 {formatRelative(pr.createdAt)}
720 </span>
721 </a>
722 ))}
723 </div>
724 )}
725 </div>
726 </>
727 )}
728
f1ab587Claude729 {/* ─── Quick Links ─── */}
dc26881CC LABS App730 <div style="margin-top: var(--space-8); display: flex; gap: var(--space-4); flex-wrap: wrap">
f1ab587Claude731 <a href="/explore" class="btn">Browse public repos</a>
732 <a href="/settings/tokens" class="btn">API tokens</a>
733 <a href="/settings/keys" class="btn">SSH keys</a>
734 </div>
735 </Layout>
736 );
737});
738
739// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
740
741dashboard.get(
742 "/:owner/:repo/settings/intelligence",
743 requireAuth,
744 async (c) => {
745 const { owner: ownerName, repo: repoName } = c.req.param();
746 const user = c.get("user")!;
747 const success = c.req.query("success");
748
749 return c.html(
750 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
751 <div style="max-width: 600px">
752 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
753 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
754 Control what gluecron does automatically when code is pushed to{" "}
755 <strong>{ownerName}/{repoName}</strong>.
756 </p>
757 {success && (
758 <div class="auth-success">{decodeURIComponent(success)}</div>
759 )}
760 <form
0316dbbClaude761 method="post"
f1ab587Claude762 action={`/${ownerName}/${repoName}/settings/intelligence`}
763 >
764 <ToggleSetting
765 name="auto_repair"
766 label="Auto-Repair"
767 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
768 defaultChecked={true}
769 />
770 <ToggleSetting
771 name="security_scan"
772 label="Security Scanning"
773 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
774 defaultChecked={true}
775 />
776 <ToggleSetting
777 name="health_score"
778 label="Health Score"
779 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
780 defaultChecked={true}
781 />
782 <ToggleSetting
783 name="push_analysis"
784 label="Push Risk Analysis"
785 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
786 defaultChecked={true}
787 />
788 <ToggleSetting
789 name="dep_analysis"
790 label="Dependency Analysis"
791 description="Build import graph, detect unused deps, find circular dependencies"
792 defaultChecked={true}
793 />
794 <ToggleSetting
795 name="gatetest"
796 label="GateTest Integration"
797 description="Send push events to GateTest for external security scanning"
798 defaultChecked={true}
799 />
800 <ToggleSetting
90fa787Claude801 name="deploy_webhook"
802 label="Auto-Deploy Webhook"
803 description="POST to your configured deploy webhook when pushing to the default branch"
f1ab587Claude804 defaultChecked={true}
805 />
806
807 <button
808 type="submit"
809 class="btn btn-primary"
810 style="margin-top: 12px"
811 >
812 Save settings
813 </button>
814 </form>
815 </div>
816 </Layout>
817 );
818 }
819);
820
821dashboard.post(
822 "/:owner/:repo/settings/intelligence",
823 requireAuth,
824 async (c) => {
825 const { owner: ownerName, repo: repoName } = c.req.param();
826 // In production, these would be saved to DB per-repo
827 // For now, acknowledge the settings
828 return c.redirect(
829 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
830 );
831 }
832);
833
834// ─── PUSH LOG ────────────────────────────────────────────────
835
836dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
837 const { owner, repo } = c.req.param();
838 const user = c.get("user");
839
840 if (!(await repoExists(owner, repo))) return c.notFound();
841 const ref = (await getDefaultBranch(owner, repo)) || "main";
842 const commits = await listCommits(owner, repo, ref, 30);
843
844 return c.html(
845 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
846 <div style="max-width: 900px">
847 <h2 style="margin-bottom: 4px">Push Log</h2>
848 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
849 Every push analyzed in real-time — risk scores, repairs, security alerts
850 </p>
851 <div class="issue-list">
852 {commits.map((commit) => {
853 // Determine if this was an auto-repair commit
854 const isRepair =
855 commit.author === "gluecron[bot]" ||
856 commit.message.includes("auto-repair");
857 const isRollback = commit.message.startsWith("revert: rollback");
858
859 return (
860 <div class="issue-item" style="flex-direction: column; align-items: stretch">
861 <div style="display: flex; justify-content: space-between; align-items: start">
dc26881CC LABS App862 <div style="display: flex; gap: var(--space-2); align-items: start">
f1ab587Claude863 {isRepair ? (
864 <span
865 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
866 title="Auto-repair"
867 >
868 {"⚡"}
869 </span>
870 ) : isRollback ? (
871 <span
872 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
873 title="Rollback"
874 >
875 {"↩"}
876 </span>
877 ) : (
878 <span
879 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
880 >
881 {"→"}
882 </span>
883 )}
884 <div>
885 <a
886 href={`/${owner}/${repo}/commit/${commit.sha}`}
887 style="font-weight: 600; font-size: 14px"
888 >
889 {commit.message}
890 </a>
891 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
892 {commit.author} —{" "}
893 {new Date(commit.date).toLocaleString("en-US", {
894 month: "short",
895 day: "numeric",
896 hour: "2-digit",
897 minute: "2-digit",
898 })}
899 </div>
900 </div>
901 </div>
902 <a
903 href={`/${owner}/${repo}/commit/${commit.sha}`}
904 class="commit-sha"
905 >
906 {commit.sha.slice(0, 7)}
907 </a>
908 </div>
909 {isRepair && (
910 <div
dc26881CC LABS App911 style="margin-top: var(--space-2); padding: var(--space-2) var(--space-3); background: rgba(63, 185, 80, 0.1); border-radius: var(--radius); font-size: 12px; color: var(--green)"
f1ab587Claude912 >
913 Automatically repaired by gluecron
914 </div>
915 )}
916 </div>
917 );
918 })}
919 </div>
920 </div>
921 </Layout>
922 );
923});
924
925// ─── COMPONENTS ──────────────────────────────────────────────
926
46d6165Claude927/**
928 * Block L9 — pure formatter used by the dashboard widget AND tests.
929 * Turns the breakdown into the small stat-pill array shown under the
930 * big number. Exported so the markup contract is testable without
931 * importing JSX.
932 */
933export function formatSavingsPills(b: {
934 prsAutoMerged: number;
935 issuesBuiltByAi: number;
936 aiReviewsPosted: number;
937 aiTriagesPosted: number;
938 aiCommitMsgs: number;
939 secretsAutoRepaired: number;
940 gateAutoRepairs: number;
941}): string[] {
942 const pills: string[] = [];
943 if (b.prsAutoMerged) pills.push(`${b.prsAutoMerged} PR${b.prsAutoMerged === 1 ? "" : "s"} auto-merged`);
944 if (b.issuesBuiltByAi) pills.push(`${b.issuesBuiltByAi} issue${b.issuesBuiltByAi === 1 ? "" : "s"} built`);
945 if (b.aiReviewsPosted) pills.push(`${b.aiReviewsPosted} AI review${b.aiReviewsPosted === 1 ? "" : "s"}`);
946 if (b.aiTriagesPosted) pills.push(`${b.aiTriagesPosted} triage${b.aiTriagesPosted === 1 ? "" : "s"}`);
947 const fixes = b.secretsAutoRepaired + b.gateAutoRepairs;
948 if (fixes) pills.push(`${fixes} auto-fix${fixes === 1 ? "" : "es"}`);
949 if (b.aiCommitMsgs) pills.push(`${b.aiCommitMsgs} commit msg${b.aiCommitMsgs === 1 ? "" : "s"}`);
950 return pills;
951}
952
953const AiHoursSavedWidget = ({
954 week,
955 lifetime,
956}: {
957 week: AiSavingsReport;
958 lifetime: AiSavingsLifetimeReport;
959}) => {
960 const weekPills = formatSavingsPills(week.breakdown);
961 const lifetimePills = formatSavingsPills(lifetime.breakdown);
962 const hasAnyWeek = week.hoursSaved > 0 || weekPills.length > 0;
963 return (
964 <div
965 class="card ai-hours-saved-widget"
966 style="margin-bottom: 24px; padding: 0; overflow: hidden; position: relative; background: var(--accent-gradient-faint, var(--bg-secondary)); border-color: var(--accent)"
967 >
dc26881CC LABS App968 <div style="padding: var(--space-6) var(--space-6) var(--space-5) var(--space-6)">
969 <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-4);flex-wrap:wrap">
46d6165Claude970 <div style="flex:1;min-width:240px">
971 <div style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px">
972 AI working for you
973 </div>
974 <div
975 data-testid="ai-hours-saved-this-week"
976 style="font-size: 56px; font-weight: 800; line-height: 1; background: var(--accent-gradient); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent"
977 >
978 {week.hoursSaved.toFixed(1)}h
979 </div>
980 <div style="margin-top: 6px; font-size: 14px; color: var(--text-muted)">
981 Claude saved you{" "}
982 <strong style="color: var(--text)">
983 {week.hoursSaved.toFixed(1)} hours
984 </strong>{" "}
985 this week.
986 {lifetime.hoursSaved > week.hoursSaved && (
987 <span>
988 {" — "}
989 <strong style="color: var(--text)">
990 {lifetime.hoursSaved.toFixed(1)}h
991 </strong>{" "}
992 all-time.
993 </span>
994 )}
995 </div>
996 </div>
997 <div
998 class="ai-hours-saved-tabs"
999 style="display:flex;gap:4px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;padding:2px"
1000 >
1001 <span
1002 data-tab="this-week"
1003 style="padding:4px 10px;font-size:12px;font-weight:600;border-radius:4px;background:var(--bg);color:var(--text)"
1004 >
1005 This week
1006 </span>
1007 <span
1008 data-tab="all-time"
1009 style="padding:4px 10px;font-size:12px;color:var(--text-muted)"
1010 >
1011 All-time
1012 </span>
1013 </div>
1014 </div>
1015
1016 {hasAnyWeek ? (
dc26881CC LABS App1017 <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-4)">
46d6165Claude1018 {weekPills.map((p) => (
1019 <span
1020 class="badge"
1021 style="font-size:12px;padding:4px 10px;background:rgba(140,109,255,0.10);border-color:var(--accent);color:var(--text)"
1022 >
1023 {p}
1024 </span>
1025 ))}
1026 </div>
1027 ) : (
1028 <div style="margin-top:16px;font-size:13px;color:var(--text-muted)">
1029 No AI activity this week yet — open a PR, label an issue{" "}
1030 <code>ai:build</code>, or let auto-merge sweep your branches.
1031 The counter will start climbing.
1032 </div>
1033 )}
1034
1035 <details style="margin-top:16px">
1036 <summary
1037 data-testid="ai-hours-saved-formula-toggle"
1038 style="cursor:pointer;font-size:12px;color:var(--text-muted);user-select:none"
1039 >
1040 How is this calculated?
1041 </summary>
1042 <div
1043 style="margin-top:10px;padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);font-size:12px;color:var(--text-muted);font-family:var(--font-mono, monospace);line-height:1.6"
1044 >
1045 <div>hoursSaved =</div>
1046 <div>&nbsp;&nbsp;{week.breakdown.prsAutoMerged} PRs auto-merged × 0.30</div>
1047 <div>+ {week.breakdown.issuesBuiltByAi} issues built by AI × 1.50</div>
1048 <div>+ {week.breakdown.aiReviewsPosted} AI reviews × 0.25</div>
1049 <div>+ {week.breakdown.aiTriagesPosted} AI triages × 0.10</div>
1050 <div>+ {week.breakdown.aiCommitMsgs} AI commit msgs × 0.05</div>
1051 <div>+ {week.breakdown.secretsAutoRepaired} secrets repaired × 0.50</div>
1052 <div>+ {week.breakdown.gateAutoRepairs} gates repaired × 0.40</div>
1053 <div style="margin-top:6px;color:var(--text)">
1054 = {week.hoursSaved.toFixed(1)}h (this week,{" "}
1055 {week.windowHours}h window)
1056 </div>
1057 <div style="margin-top:8px;font-size:11px">
1058 Lifetime: {lifetime.hoursSaved.toFixed(1)}h since{" "}
1059 {lifetime.sinceCreatedAt.toISOString().slice(0, 10)}.
1060 Constants are conservative on purpose — audit-friendly is
1061 the brand.
1062 </div>
1063 {lifetimePills.length > 0 && (
1064 <div style="margin-top:8px;font-size:11px">
1065 All-time breakdown: {lifetimePills.join(" · ")}
1066 </div>
1067 )}
1068 </div>
1069 </details>
1070 </div>
1071 </div>
1072 );
1073};
1074
7a14837Claude1075/**
1076 * Pure helper: pick the bottom-N repos by health score and return a
1077 * prioritized "fix this next" plan. Health=0 repos (couldn't be
1078 * computed) are excluded so the coach doesn't recommend ghost repos.
1079 *
1080 * Exported under __test for unit testing without touching the DB.
1081 */
1082export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
1083 repoData: T[],
1084 topN = 3
1085): T[] {
1086 return repoData
1087 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
1088 .sort((a, b) => a.healthScore - b.healthScore)
1089 .slice(0, topN);
1090}
1091
1092/** Module-scoped color picker for grade chips. Mirrors the inner
1093 * `gradeColor` defined in the request handler scope, exposed at module
1094 * level so HealthCoach (also module-scope) can reach it. */
1095function moduleGradeColor(grade: string): string {
1096 if (grade === "A+" || grade === "A") return "var(--green)";
1097 if (grade === "B") return "#58a6ff";
1098 if (grade === "C") return "var(--yellow)";
1099 if (grade === "?") return "var(--text-muted)";
1100 return "var(--red)";
1101}
1102
1103const HealthCoach = ({
1104 repoData,
1105 username,
1106}: {
1107 repoData: Array<{
1108 repo: { name: string; description: string | null };
1109 healthScore: number;
1110 healthGrade: string;
1111 }>;
1112 username: string;
1113}) => {
1114 const picks = pickRepoCoachPicks(repoData, 3);
1115 if (picks.length === 0) {
1116 return (
1117 <div
1118 class="card"
dc26881CC LABS App1119 style="margin-bottom: var(--space-8); padding: var(--space-4); background: rgba(63,185,80,0.08); border-color: var(--green)"
7a14837Claude1120 >
1121 <h3 style="margin: 0 0 4px; font-size: 15px">
1122 {"✨"} AI Health Coach
1123 </h3>
1124 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
1125 All your repos are healthy (score &ge; 90). Nothing to triage.
1126 </p>
1127 </div>
1128 );
1129 }
1130 return (
1131 <div
1132 class="card"
1133 style="margin-bottom: 32px; padding: 0; overflow: hidden"
1134 >
1135 <div
dc26881CC LABS App1136 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
7a14837Claude1137 >
1138 <div>
1139 <h3 style="margin: 0; font-size: 15px">
1140 {"✨"} AI Health Coach
1141 </h3>
1142 <p
dc26881CC LABS App1143 style="margin: var(--space-1) 0 0; color: var(--text-muted); font-size: 12px"
7a14837Claude1144 >
1145 Top {picks.length} repos that would benefit from attention
1146 this week.
1147 </p>
1148 </div>
1149 </div>
1150 <ul style="list-style: none; margin: 0; padding: 0">
1151 {picks.map((p) => (
1152 <li
dc26881CC LABS App1153 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: var(--space-3)"
7a14837Claude1154 >
1155 <div
1156 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
1157 >
1158 {p.healthGrade}
1159 </div>
1160 <div style="flex: 1; min-width: 0">
1161 <a
1162 href={`/${username}/${p.repo.name}`}
1163 style="font-weight: 500"
1164 >
1165 {p.repo.name}
1166 </a>
1167 <div
1168 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
1169 >
1170 Health score {p.healthScore}/100 — open the repo to see
1171 breakdown + AI suggestions.
1172 </div>
1173 </div>
1174 <a
f077ea5Claude1175 href={`/${username}/${p.repo.name}/health`}
7a14837Claude1176 class="btn"
1177 style="font-size: 12px; padding: 4px 10px"
f077ea5Claude1178 title="Open health score with AI suggestions"
7a14837Claude1179 >
1180 Coach me
1181 </a>
1182 </li>
1183 ))}
1184 </ul>
1185 </div>
1186 );
1187};
1188
f1ab587Claude1189const StatBox = ({
1190 label,
1191 value,
1192 color,
1193}: {
1194 label: string;
1195 value: string;
1196 color: string;
1197}) => (
1198 <div
dc26881CC LABS App1199 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); text-align: center"
f1ab587Claude1200 >
1201 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
1202 {value}
1203 </div>
1204 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
1205 {label}
1206 </div>
1207 </div>
1208);
1209
1210const ToggleSetting = ({
1211 name,
1212 label,
1213 description,
1214 defaultChecked,
1215}: {
1216 name: string;
1217 label: string;
1218 description: string;
1219 defaultChecked: boolean;
1220}) => (
1221 <div
dc26881CC LABS App1222 style="display: flex; justify-content: space-between; align-items: start; padding: var(--space-4) 0; border-bottom: 1px solid var(--border)"
f1ab587Claude1223 >
1224 <div style="flex: 1">
1225 <div style="font-size: 15px; font-weight: 600">{label}</div>
1226 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
1227 {description}
1228 </div>
1229 </div>
1230 <label class="toggle-switch">
2228c49copilot-swe-agent[bot]1231 <input type="checkbox" name={name} value="on" checked={defaultChecked} aria-label={label} />
f1ab587Claude1232 <span class="toggle-slider" />
1233 </label>
1234 </div>
1235);
1236
1237const ActivityIcon = ({ action }: { action: string }) => {
1238 const icons: Record<string, string> = {
1239 push: "→",
1240 issue_open: "\u25CB",
1241 issue_close: "\u2713",
1242 pr_open: "\u25CB",
1243 pr_merge: "\u2B8C",
1244 star: "\u2605",
1245 fork: "\u2442",
1246 comment: "\u{1F4AC}",
1247 };
1248 return (
1249 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
1250 {icons[action] || "•"}
1251 </span>
1252 );
1253};
1254
1255function formatAction(action: string): string {
1256 const labels: Record<string, string> = {
1257 push: "Pushed code",
1258 issue_open: "Opened issue",
1259 issue_close: "Closed issue",
1260 pr_open: "Opened pull request",
1261 pr_merge: "Merged pull request",
1262 star: "Starred",
1263 fork: "Forked",
1264 comment: "Commented",
1265 };
1266 return labels[action] || action;
1267}
1268
1269function formatRelative(date: Date | string): string {
1270 const d = typeof date === "string" ? new Date(date) : date;
1271 const now = new Date();
1272 const diffMs = now.getTime() - d.getTime();
1273 const diffMins = Math.floor(diffMs / 60000);
1274 if (diffMins < 1) return "just now";
1275 if (diffMins < 60) return `${diffMins}m ago`;
1276 const diffHours = Math.floor(diffMins / 60);
1277 if (diffHours < 24) return `${diffHours}h ago`;
1278 const diffDays = Math.floor(diffHours / 24);
1279 if (diffDays < 30) return `${diffDays}d ago`;
1280 return d.toLocaleDateString("en-US", {
1281 month: "short",
1282 day: "numeric",
1283 });
1284}
1285
f1dc7c7Claude1286// ─── Loading skeleton (flag-gated; renders when ?skeleton=1) ──────────
1287const DashboardSkeleton = () => (
1288 <>
1289 <style
1290 dangerouslySetInnerHTML={{
1291 __html: `
1292 .dash-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: dashSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
1293 @keyframes dashSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1294 @media (prefers-reduced-motion: reduce) { .dash-skel { animation: none; } }
1295 .dash-skel-hero { height: 168px; border-radius: 16px; margin-bottom: var(--space-6); }
1296 .dash-skel-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8); }
1297 .dash-skel-stat { height: 86px; border-radius: var(--radius); }
1298 .dash-skel-h { height: 18px; width: 180px; margin: 0 0 16px; border-radius: 5px; }
1299 .dash-skel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8); }
1300 .dash-skel-card { height: 168px; border-radius: var(--radius); }
1301 .dash-skel-feed { display: flex; flex-direction: column; gap: 8px; }
1302 .dash-skel-feed-row { height: 52px; border-radius: var(--radius); }
1303 `,
1304 }}
1305 />
1306 <div class="dash-skel dash-skel-hero" aria-hidden="true" />
1307 <div class="dash-skel-stats" aria-hidden="true">
1308 <div class="dash-skel dash-skel-stat" />
1309 <div class="dash-skel dash-skel-stat" />
1310 <div class="dash-skel dash-skel-stat" />
1311 <div class="dash-skel dash-skel-stat" />
1312 </div>
1313 <div class="dash-skel dash-skel-h" aria-hidden="true" />
1314 <div class="dash-skel-grid" aria-hidden="true">
1315 <div class="dash-skel dash-skel-card" />
1316 <div class="dash-skel dash-skel-card" />
1317 <div class="dash-skel dash-skel-card" />
1318 <div class="dash-skel dash-skel-card" />
1319 </div>
1320 <div class="dash-skel dash-skel-h" aria-hidden="true" />
1321 <div class="dash-skel-feed" aria-hidden="true">
1322 <div class="dash-skel dash-skel-feed-row" />
1323 <div class="dash-skel dash-skel-feed-row" />
1324 <div class="dash-skel dash-skel-feed-row" />
1325 <div class="dash-skel dash-skel-feed-row" />
1326 <div class="dash-skel dash-skel-feed-row" />
1327 </div>
1328 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
1329 Loading your command center…
1330 </span>
1331 </>
1332);
1333
c6018a5Claude1334// ─── Quick actions — 5-card panel of the most-leverage AI features.
1335// Surfaces the post-K1 AI surfaces (Voice, Standups, Refactors, repo
1336// chat, Pulls dashboard) so signed-in users can discover them without
1337// hunting through the top-nav dropdown. CSS scoped under `.qa-` to
1338// avoid bleed; no shared component touched.
1339const QuickActionsPanel = ({
1340 firstRepo,
1341 username,
1342}: {
1343 firstRepo: { name: string } | undefined;
1344 username: string;
1345}) => {
1346 const chatHref = firstRepo
1347 ? `/${username}/${firstRepo.name}/chat`
1348 : "/explore";
1349 const chatSub = firstRepo
1350 ? `Open ${firstRepo.name} rubber-duck chat`
1351 : "Pick a repo, then ask anything";
1352 return (
1353 <div class="qa-panel" aria-label="Quick AI actions">
1354 <style
1355 dangerouslySetInnerHTML={{
1356 __html: `
1357 .qa-panel {
1358 position: relative;
1359 margin-bottom: var(--space-6);
1360 padding: var(--space-4) var(--space-5) var(--space-5);
1361 background: var(--bg-elevated);
1362 border: 1px solid var(--border);
1363 border-radius: 14px;
1364 overflow: hidden;
1365 }
1366 .qa-panel::before {
1367 content: '';
1368 position: absolute;
1369 top: 0; left: 0; right: 0;
1370 height: 1px;
1371 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1372 opacity: 0.65;
1373 pointer-events: none;
1374 }
1375 .qa-eyebrow {
1376 font-size: 11px;
1377 font-weight: 600;
1378 letter-spacing: 0.08em;
1379 text-transform: uppercase;
1380 color: var(--accent);
1381 margin-bottom: 4px;
1382 }
1383 .qa-title {
1384 font-family: var(--font-display);
1385 font-size: 17px;
1386 font-weight: 700;
1387 letter-spacing: -0.018em;
1388 margin: 0 0 var(--space-3);
1389 color: var(--text-strong);
1390 }
1391 .qa-grid {
1392 display: grid;
1393 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1394 gap: var(--space-2);
1395 }
1396 .qa-card {
1397 display: flex;
1398 flex-direction: column;
1399 gap: 4px;
1400 padding: var(--space-3) var(--space-3);
1401 background: var(--bg);
1402 border: 1px solid var(--border);
1403 border-radius: 10px;
1404 color: var(--text);
1405 text-decoration: none;
1406 transition: border-color 160ms ease, transform 160ms ease, background 160ms ease;
1407 position: relative;
1408 overflow: hidden;
1409 }
1410 .qa-card::before {
1411 content: '';
1412 position: absolute;
1413 top: 0; left: 0; right: 0;
1414 height: 1px;
1415 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.55) 30%, rgba(54,197,214,0.55) 70%, transparent 100%);
1416 opacity: 0;
1417 transition: opacity 160ms ease;
1418 }
1419 .qa-card:hover {
1420 border-color: rgba(140,109,255,0.40);
1421 text-decoration: none;
1422 transform: translateY(-1px);
1423 background: linear-gradient(180deg, rgba(140,109,255,0.04), transparent);
1424 }
1425 .qa-card:hover::before { opacity: 1; }
1426 .qa-card-label {
1427 font-size: 13.5px;
1428 font-weight: 600;
1429 color: var(--text-strong);
1430 display: flex;
1431 align-items: center;
1432 gap: 6px;
1433 }
1434 .qa-card-sub {
1435 font-size: 12px;
1436 color: var(--text-muted);
1437 line-height: 1.4;
1438 }
1439 .qa-card-icon {
1440 display: inline-flex;
1441 width: 18px; height: 18px;
1442 align-items: center;
1443 justify-content: center;
1444 border-radius: 5px;
1445 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.14));
1446 color: #b69dff;
1447 font-size: 11px;
1448 font-weight: 700;
1449 flex-shrink: 0;
1450 }
1451 `,
1452 }}
1453 />
1454 <div class="qa-eyebrow">Quick actions</div>
1455 <h3 class="qa-title">Ship faster with Claude</h3>
1456 <div class="qa-grid">
1457 <a href="/voice" class="qa-card">
1458 <span class="qa-card-label">
1459 <span class="qa-card-icon" aria-hidden="true">{"\u{1F3A4}"}</span>
1460 Talk to ship
1461 </span>
1462 <span class="qa-card-sub">Voice → PR in one take</span>
1463 </a>
1464 <a href="/standups" class="qa-card">
1465 <span class="qa-card-label">
1466 <span class="qa-card-icon" aria-hidden="true">{"\u{1F4DD}"}</span>
1467 Today's standup
1468 </span>
1469 <span class="qa-card-sub">Daily AI brief, fresh on demand</span>
1470 </a>
1471 <a href="/refactors" class="qa-card">
1472 <span class="qa-card-label">
1473 <span class="qa-card-icon" aria-hidden="true">{"⚡"}</span>
1474 Refactor everywhere
1475 </span>
1476 <span class="qa-card-sub">One brief → PRs across all repos</span>
1477 </a>
1478 <a href={chatHref} class="qa-card">
1479 <span class="qa-card-label">
1480 <span class="qa-card-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
1481 Chat with a repo
1482 </span>
1483 <span class="qa-card-sub">{chatSub}</span>
1484 </a>
1485 <a href="/pulls" class="qa-card">
1486 <span class="qa-card-label">
1487 <span class="qa-card-icon" aria-hidden="true">{"\u{1F500}"}</span>
1488 Watch the PR queue
1489 </span>
1490 <span class="qa-card-sub">Global pulls across your repos</span>
1491 </a>
1492 </div>
1493 </div>
1494 );
1495};
1496
f1ab587Claude1497export default dashboard;