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.tsxBlame1013 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";
18import { eq, desc, and } 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
f1ab587Claude69 // Get all user's repos
70 const repos = await db
71 .select()
72 .from(repositories)
73 .where(eq(repositories.ownerId, user.id))
74 .orderBy(desc(repositories.updatedAt));
75
76 // Compute health scores for all repos (in parallel)
77 const repoData = await Promise.all(
78 repos.map(async (repo) => {
79 let healthScore = 0;
80 let healthGrade = "?" as string;
81 let recentCommits = 0;
82 let branchCount = 0;
83 let ciConfig = null;
84
85 try {
86 if (await repoExists(user.username, repo.name)) {
87 const ref =
88 (await getDefaultBranch(user.username, repo.name)) || "main";
89 const [health, commits, branches, ci] = await Promise.all([
90 computeHealthScore(user.username, repo.name).catch(() => null),
91 listCommits(user.username, repo.name, ref, 5).catch(() => []),
92 listBranches(user.username, repo.name).catch(() => []),
93 detectCIConfig(user.username, repo.name, ref).catch(() => null),
94 ]);
95 if (health) {
96 healthScore = health.score;
97 healthGrade = health.grade;
98 }
99 recentCommits = commits.length;
100 branchCount = branches.length;
101 ciConfig = ci;
102 }
103 } catch {
104 // best effort
105 }
106
107 return {
108 repo,
109 healthScore,
110 healthGrade,
111 recentCommits,
112 branchCount,
113 ciConfig,
114 };
115 })
116 );
117
46d6165Claude118 // Block L9 — AI hours-saved counter. Pull both window + lifetime in
119 // parallel; both helpers swallow DB errors so the dashboard always renders.
120 const [savingsWeek, savingsLifetime] = await Promise.all([
121 computeAiSavingsForUser(user.id, { windowHours: 168 }),
122 computeLifetimeAiSavingsForUser(user.id),
123 ]);
124
f1ab587Claude125 // Get recent activity
126 let recentActivity: Array<{
127 action: string;
128 repoName: string;
129 metadata: string | null;
130 createdAt: Date;
131 }> = [];
132
133 try {
134 const repoIds = repos.map((r) => r.id);
135 if (repoIds.length > 0) {
136 const activity = await db
137 .select({
138 action: activityFeed.action,
139 metadata: activityFeed.metadata,
140 createdAt: activityFeed.createdAt,
141 repoId: activityFeed.repositoryId,
142 })
143 .from(activityFeed)
144 .where(eq(activityFeed.userId, user.id))
145 .orderBy(desc(activityFeed.createdAt))
146 .limit(20);
147
148 recentActivity = activity.map((a) => ({
149 action: a.action,
150 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
151 metadata: a.metadata,
152 createdAt: a.createdAt,
153 }));
154 }
155 } catch {
156 // DB not required for dashboard
157 }
158
159 const gradeColor = (grade: string) =>
160 grade === "A+" || grade === "A"
161 ? "var(--green)"
162 : grade === "B"
163 ? "#58a6ff"
164 : grade === "C"
165 ? "var(--yellow)"
166 : grade === "?"
167 ? "var(--text-muted)"
168 : "var(--red)";
169
c63b860Claude170 // Block P2 — email verification banner. Shows when the user hasn't
171 // verified yet AND they haven't dismissed it this session. Also surfaces
172 // transient resend feedback (`?verify=sent` / `?verify=rate_limited`)
173 // and the post-register hint (`?welcome=1`).
174 const verifyDismissed = getCookie(c, "p2_verify_dismissed") === "1";
175 const showVerifyBanner =
176 !(user as any).emailVerifiedAt && !verifyDismissed;
177 const verifyQuery = c.req.query("verify");
178 const welcomeQuery = c.req.query("welcome");
179
f1ab587Claude180 return c.html(
181 <Layout title="Command Center" user={user}>
c63b860Claude182 {showVerifyBanner && (
183 <div
dc26881CC LABS App184 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"
c63b860Claude185 data-p2-verify-banner=""
186 >
187 <div style="flex: 1 1 auto; min-width: 0">
188 {welcomeQuery === "1" ? (
189 <span>
190 Welcome to Gluecron! Check your inbox to verify your email.
191 </span>
192 ) : verifyQuery === "sent" ? (
193 <span>
194 Verification link sent. It may take a minute to arrive.
195 </span>
196 ) : verifyQuery === "rate_limited" ? (
197 <span>
198 You've requested too many verification emails. Try again later.
199 </span>
826eccfTest User200 ) : verifyQuery === "not_configured" ? (
201 <span>
202 Email delivery isn't configured on this instance yet — your
203 site admin needs to set <code>EMAIL_PROVIDER=resend</code> and{" "}
204 <code>RESEND_API_KEY</code>. Until then the verification link
205 is written to the server log.
206 </span>
c63b860Claude207 ) : (
208 <span>Verify your email to keep using Gluecron.</span>
209 )}
210 </div>
211 <form
212 method="post"
213 action="/verify-email/resend"
dc26881CC LABS App214 style="display: inline-flex; gap: var(--space-2); align-items: center; margin: 0"
c63b860Claude215 >
216 <input
217 type="hidden"
218 name="_csrf"
219 value={(c.get("csrfToken") as string | undefined) || ""}
220 />
221 <button
222 type="submit"
223 class="btn"
224 style="padding: 4px 10px; font-size: 12px"
225 >
226 Resend verification link
227 </button>
228 <a
229 href="/dashboard?p2_dismiss=1"
230 class="btn"
231 style="padding: 4px 10px; font-size: 12px"
232 aria-label="Dismiss verification banner"
233 >
234 Dismiss
235 </a>
236 </form>
237 </div>
238 )}
f1ab587Claude239 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px">
240 <div>
241 <h1 style="font-size: 28px; margin-bottom: 4px">Command Center</h1>
242 <p style="color: var(--text-muted); font-size: 14px">
243 Real-time overview of all your repositories
244 </p>
245 </div>
dc26881CC LABS App246 <div style="display: flex; gap: var(--space-2)">
f1ab587Claude247 <a href="/new" class="btn btn-primary">+ New repo</a>
248 <a href="/settings" class="btn">Settings</a>
249 </div>
250 </div>
251
46d6165Claude252 {/* ─── L9: AI hours-saved hero widget ─── */}
253 <AiHoursSavedWidget week={savingsWeek} lifetime={savingsLifetime} />
254
f1ab587Claude255 {/* ─── Stats Bar ─── */}
dc26881CC LABS App256 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8)">
f1ab587Claude257 <StatBox
258 label="Repositories"
259 value={String(repos.length)}
260 color="var(--text-link)"
261 />
262 <StatBox
263 label="Avg Health"
264 value={
265 repos.length > 0
266 ? String(
267 Math.round(
268 repoData.reduce((s, r) => s + r.healthScore, 0) /
269 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
270 )
271 )
272 : "—"
273 }
274 color="var(--green)"
275 />
276 <StatBox
277 label="Total Stars"
278 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
279 color="var(--yellow)"
280 />
281 <StatBox
282 label="Open Issues"
283 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
284 color="var(--red)"
285 />
286 </div>
287
288 {/* ─── Repo Grid ─── */}
289 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
290 {repos.length === 0 ? (
dc26881CC LABS App291 <div class="empty-state" style="text-align:left;padding:var(--space-6)">
80bed05Claude292 <div style="text-align:center;margin-bottom:20px">
293 <h2 style="margin-bottom:6px">Get started</h2>
294 <p style="color:var(--text-muted);font-size:14px;margin:0">
295 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
296 </p>
297 </div>
298 <div class="panel" style="margin-bottom:20px;text-align:left">
dc26881CC LABS App299 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude300 <div style="flex:1">
301 <div style="font-size:15px;font-weight:600">Create a new repository</div>
302 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
303 Start from scratch with green-ecosystem defaults.
304 </div>
305 </div>
306 <a href="/new" class="btn btn-primary">Create repo</a>
307 </div>
dc26881CC LABS App308 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude309 <div style="flex:1">
310 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
311 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
312 Mirror an existing repo — history, branches, tags.
313 </div>
314 </div>
315 <a href="/import" class="btn">Import repo</a>
316 </div>
dc26881CC LABS App317 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude318 <div style="flex:1">
319 <div style="font-size:15px;font-weight:600">Browse public repos</div>
320 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
321 See what others are building, fork what you like.
322 </div>
323 </div>
324 <a href="/explore" class="btn">Browse</a>
325 </div>
326 </div>
dc26881CC LABS App327 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-4)">
80bed05Claude328 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
329 Push an existing project (preview)
330 </div>
331 <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.
332git remote add gluecron http://localhost:3000/{user.username}/&lt;your-repo&gt;.git
333git push -u gluecron main</code></pre>
334 </div>
f1ab587Claude335 </div>
336 ) : (
dc26881CC LABS App337 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8)">
f1ab587Claude338 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
339 <div class="card" style="padding: 0; overflow: hidden">
340 {/* Health bar at top */}
341 <div
342 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
343 />
dc26881CC LABS App344 <div style="padding: var(--space-4)">
f1ab587Claude345 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
346 <div>
347 <h3 style="font-size: 16px; margin-bottom: 2px">
348 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
349 </h3>
350 {repo.description && (
351 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
352 {repo.description}
353 </p>
354 )}
355 </div>
356 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
357 <div
358 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
359 >
360 {healthGrade}
361 </div>
362 <div style="font-size: 10px; color: var(--text-muted)">
363 {healthScore}/100
364 </div>
365 </div>
366 </div>
367
dc26881CC LABS App368 <div style="display: flex; gap: var(--space-4); font-size: 12px; color: var(--text-muted); margin-top: var(--space-2)">
f1ab587Claude369 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
370 <span>{"\u2606"} {repo.starCount}</span>
371 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
372 </div>
373
374 {ciConfig && ciConfig.commands.length > 0 && (
dc26881CC LABS App375 <div style="margin-top: var(--space-2); display: flex; gap: 6px; flex-wrap: wrap">
f1ab587Claude376 {ciConfig.detected.slice(0, 3).map((d) => (
377 <span
378 class="badge"
379 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
380 >
381 {d}
382 </span>
383 ))}
384 </div>
385 )}
386
dc26881CC LABS App387 <div style="display: flex; gap: 6px; margin-top: var(--space-3)">
f1ab587Claude388 <a
389 href={`/${user.username}/${repo.name}/health`}
390 class="btn btn-sm"
391 style="font-size: 11px; padding: 2px 8px"
392 >
393 Health
394 </a>
395 <a
396 href={`/${user.username}/${repo.name}/dependencies`}
397 class="btn btn-sm"
398 style="font-size: 11px; padding: 2px 8px"
399 >
400 Deps
401 </a>
402 <a
403 href={`/${user.username}/${repo.name}/coupling`}
404 class="btn btn-sm"
405 style="font-size: 11px; padding: 2px 8px"
406 >
407 Insights
408 </a>
409 <a
410 href={`/${user.username}/${repo.name}/settings`}
411 class="btn btn-sm"
412 style="font-size: 11px; padding: 2px 8px"
413 >
414 Settings
415 </a>
416 </div>
417 </div>
418 </div>
419 ))}
420 </div>
421 )}
422
423 {/* ─── Activity Feed ─── */}
424 {recentActivity.length > 0 && (
425 <>
426 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
427 <div class="issue-list">
428 {recentActivity.map((a) => (
429 <div class="issue-item">
dc26881CC LABS App430 <div style="display: flex; gap: var(--space-2); align-items: center">
f1ab587Claude431 <ActivityIcon action={a.action} />
432 <div>
433 <span style="font-size: 14px">
434 {formatAction(a.action)} in{" "}
435 <a href={`/${user.username}/${a.repoName}`}>
436 {a.repoName}
437 </a>
438 </span>
439 <div style="font-size: 12px; color: var(--text-muted)">
440 {formatRelative(a.createdAt)}
441 </div>
442 </div>
443 </div>
444 </div>
445 ))}
446 </div>
447 </>
448 )}
449
7a14837Claude450 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
451 <HealthCoach repoData={repoData} username={user.username} />
452
febd4f0Claude453 {/* ─── Live Activity (SSE) ─── */}
454 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
455
f1ab587Claude456 {/* ─── Quick Links ─── */}
dc26881CC LABS App457 <div style="margin-top: var(--space-8); display: flex; gap: var(--space-4); flex-wrap: wrap">
f1ab587Claude458 <a href="/explore" class="btn">Browse public repos</a>
459 <a href="/settings/tokens" class="btn">API tokens</a>
460 <a href="/settings/keys" class="btn">SSH keys</a>
461 </div>
462 </Layout>
463 );
464});
465
466// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
467
468dashboard.get(
469 "/:owner/:repo/settings/intelligence",
470 requireAuth,
471 async (c) => {
472 const { owner: ownerName, repo: repoName } = c.req.param();
473 const user = c.get("user")!;
474 const success = c.req.query("success");
475
476 return c.html(
477 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
478 <div style="max-width: 600px">
479 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
480 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
481 Control what gluecron does automatically when code is pushed to{" "}
482 <strong>{ownerName}/{repoName}</strong>.
483 </p>
484 {success && (
485 <div class="auth-success">{decodeURIComponent(success)}</div>
486 )}
487 <form
0316dbbClaude488 method="post"
f1ab587Claude489 action={`/${ownerName}/${repoName}/settings/intelligence`}
490 >
491 <ToggleSetting
492 name="auto_repair"
493 label="Auto-Repair"
494 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
495 defaultChecked={true}
496 />
497 <ToggleSetting
498 name="security_scan"
499 label="Security Scanning"
500 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
501 defaultChecked={true}
502 />
503 <ToggleSetting
504 name="health_score"
505 label="Health Score"
506 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
507 defaultChecked={true}
508 />
509 <ToggleSetting
510 name="push_analysis"
511 label="Push Risk Analysis"
512 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
513 defaultChecked={true}
514 />
515 <ToggleSetting
516 name="dep_analysis"
517 label="Dependency Analysis"
518 description="Build import graph, detect unused deps, find circular dependencies"
519 defaultChecked={true}
520 />
521 <ToggleSetting
522 name="gatetest"
523 label="GateTest Integration"
524 description="Send push events to GateTest for external security scanning"
525 defaultChecked={true}
526 />
527 <ToggleSetting
90fa787Claude528 name="deploy_webhook"
529 label="Auto-Deploy Webhook"
530 description="POST to your configured deploy webhook when pushing to the default branch"
f1ab587Claude531 defaultChecked={true}
532 />
533
534 <button
535 type="submit"
536 class="btn btn-primary"
537 style="margin-top: 12px"
538 >
539 Save settings
540 </button>
541 </form>
542 </div>
543 </Layout>
544 );
545 }
546);
547
548dashboard.post(
549 "/:owner/:repo/settings/intelligence",
550 requireAuth,
551 async (c) => {
552 const { owner: ownerName, repo: repoName } = c.req.param();
553 // In production, these would be saved to DB per-repo
554 // For now, acknowledge the settings
555 return c.redirect(
556 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
557 );
558 }
559);
560
561// ─── PUSH LOG ────────────────────────────────────────────────
562
563dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
564 const { owner, repo } = c.req.param();
565 const user = c.get("user");
566
567 if (!(await repoExists(owner, repo))) return c.notFound();
568 const ref = (await getDefaultBranch(owner, repo)) || "main";
569 const commits = await listCommits(owner, repo, ref, 30);
570
571 return c.html(
572 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
573 <div style="max-width: 900px">
574 <h2 style="margin-bottom: 4px">Push Log</h2>
575 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
576 Every push analyzed in real-time — risk scores, repairs, security alerts
577 </p>
578 <div class="issue-list">
579 {commits.map((commit) => {
580 // Determine if this was an auto-repair commit
581 const isRepair =
582 commit.author === "gluecron[bot]" ||
583 commit.message.includes("auto-repair");
584 const isRollback = commit.message.startsWith("revert: rollback");
585
586 return (
587 <div class="issue-item" style="flex-direction: column; align-items: stretch">
588 <div style="display: flex; justify-content: space-between; align-items: start">
dc26881CC LABS App589 <div style="display: flex; gap: var(--space-2); align-items: start">
f1ab587Claude590 {isRepair ? (
591 <span
592 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
593 title="Auto-repair"
594 >
595 {"⚡"}
596 </span>
597 ) : isRollback ? (
598 <span
599 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
600 title="Rollback"
601 >
602 {"↩"}
603 </span>
604 ) : (
605 <span
606 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
607 >
608 {"→"}
609 </span>
610 )}
611 <div>
612 <a
613 href={`/${owner}/${repo}/commit/${commit.sha}`}
614 style="font-weight: 600; font-size: 14px"
615 >
616 {commit.message}
617 </a>
618 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
619 {commit.author} —{" "}
620 {new Date(commit.date).toLocaleString("en-US", {
621 month: "short",
622 day: "numeric",
623 hour: "2-digit",
624 minute: "2-digit",
625 })}
626 </div>
627 </div>
628 </div>
629 <a
630 href={`/${owner}/${repo}/commit/${commit.sha}`}
631 class="commit-sha"
632 >
633 {commit.sha.slice(0, 7)}
634 </a>
635 </div>
636 {isRepair && (
637 <div
dc26881CC LABS App638 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)"
f1ab587Claude639 >
640 Automatically repaired by gluecron
641 </div>
642 )}
643 </div>
644 );
645 })}
646 </div>
647 </div>
648 </Layout>
649 );
650});
651
652// ─── COMPONENTS ──────────────────────────────────────────────
653
46d6165Claude654/**
655 * Block L9 — pure formatter used by the dashboard widget AND tests.
656 * Turns the breakdown into the small stat-pill array shown under the
657 * big number. Exported so the markup contract is testable without
658 * importing JSX.
659 */
660export function formatSavingsPills(b: {
661 prsAutoMerged: number;
662 issuesBuiltByAi: number;
663 aiReviewsPosted: number;
664 aiTriagesPosted: number;
665 aiCommitMsgs: number;
666 secretsAutoRepaired: number;
667 gateAutoRepairs: number;
668}): string[] {
669 const pills: string[] = [];
670 if (b.prsAutoMerged) pills.push(`${b.prsAutoMerged} PR${b.prsAutoMerged === 1 ? "" : "s"} auto-merged`);
671 if (b.issuesBuiltByAi) pills.push(`${b.issuesBuiltByAi} issue${b.issuesBuiltByAi === 1 ? "" : "s"} built`);
672 if (b.aiReviewsPosted) pills.push(`${b.aiReviewsPosted} AI review${b.aiReviewsPosted === 1 ? "" : "s"}`);
673 if (b.aiTriagesPosted) pills.push(`${b.aiTriagesPosted} triage${b.aiTriagesPosted === 1 ? "" : "s"}`);
674 const fixes = b.secretsAutoRepaired + b.gateAutoRepairs;
675 if (fixes) pills.push(`${fixes} auto-fix${fixes === 1 ? "" : "es"}`);
676 if (b.aiCommitMsgs) pills.push(`${b.aiCommitMsgs} commit msg${b.aiCommitMsgs === 1 ? "" : "s"}`);
677 return pills;
678}
679
680const AiHoursSavedWidget = ({
681 week,
682 lifetime,
683}: {
684 week: AiSavingsReport;
685 lifetime: AiSavingsLifetimeReport;
686}) => {
687 const weekPills = formatSavingsPills(week.breakdown);
688 const lifetimePills = formatSavingsPills(lifetime.breakdown);
689 const hasAnyWeek = week.hoursSaved > 0 || weekPills.length > 0;
690 return (
691 <div
692 class="card ai-hours-saved-widget"
693 style="margin-bottom: 24px; padding: 0; overflow: hidden; position: relative; background: var(--accent-gradient-faint, var(--bg-secondary)); border-color: var(--accent)"
694 >
dc26881CC LABS App695 <div style="padding: var(--space-6) var(--space-6) var(--space-5) var(--space-6)">
696 <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-4);flex-wrap:wrap">
46d6165Claude697 <div style="flex:1;min-width:240px">
698 <div style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px">
699 AI working for you
700 </div>
701 <div
702 data-testid="ai-hours-saved-this-week"
703 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"
704 >
705 {week.hoursSaved.toFixed(1)}h
706 </div>
707 <div style="margin-top: 6px; font-size: 14px; color: var(--text-muted)">
708 Claude saved you{" "}
709 <strong style="color: var(--text)">
710 {week.hoursSaved.toFixed(1)} hours
711 </strong>{" "}
712 this week.
713 {lifetime.hoursSaved > week.hoursSaved && (
714 <span>
715 {" — "}
716 <strong style="color: var(--text)">
717 {lifetime.hoursSaved.toFixed(1)}h
718 </strong>{" "}
719 all-time.
720 </span>
721 )}
722 </div>
723 </div>
724 <div
725 class="ai-hours-saved-tabs"
726 style="display:flex;gap:4px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;padding:2px"
727 >
728 <span
729 data-tab="this-week"
730 style="padding:4px 10px;font-size:12px;font-weight:600;border-radius:4px;background:var(--bg);color:var(--text)"
731 >
732 This week
733 </span>
734 <span
735 data-tab="all-time"
736 style="padding:4px 10px;font-size:12px;color:var(--text-muted)"
737 >
738 All-time
739 </span>
740 </div>
741 </div>
742
743 {hasAnyWeek ? (
dc26881CC LABS App744 <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-4)">
46d6165Claude745 {weekPills.map((p) => (
746 <span
747 class="badge"
748 style="font-size:12px;padding:4px 10px;background:rgba(140,109,255,0.10);border-color:var(--accent);color:var(--text)"
749 >
750 {p}
751 </span>
752 ))}
753 </div>
754 ) : (
755 <div style="margin-top:16px;font-size:13px;color:var(--text-muted)">
756 No AI activity this week yet — open a PR, label an issue{" "}
757 <code>ai:build</code>, or let auto-merge sweep your branches.
758 The counter will start climbing.
759 </div>
760 )}
761
762 <details style="margin-top:16px">
763 <summary
764 data-testid="ai-hours-saved-formula-toggle"
765 style="cursor:pointer;font-size:12px;color:var(--text-muted);user-select:none"
766 >
767 How is this calculated?
768 </summary>
769 <div
770 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"
771 >
772 <div>hoursSaved =</div>
773 <div>&nbsp;&nbsp;{week.breakdown.prsAutoMerged} PRs auto-merged × 0.30</div>
774 <div>+ {week.breakdown.issuesBuiltByAi} issues built by AI × 1.50</div>
775 <div>+ {week.breakdown.aiReviewsPosted} AI reviews × 0.25</div>
776 <div>+ {week.breakdown.aiTriagesPosted} AI triages × 0.10</div>
777 <div>+ {week.breakdown.aiCommitMsgs} AI commit msgs × 0.05</div>
778 <div>+ {week.breakdown.secretsAutoRepaired} secrets repaired × 0.50</div>
779 <div>+ {week.breakdown.gateAutoRepairs} gates repaired × 0.40</div>
780 <div style="margin-top:6px;color:var(--text)">
781 = {week.hoursSaved.toFixed(1)}h (this week,{" "}
782 {week.windowHours}h window)
783 </div>
784 <div style="margin-top:8px;font-size:11px">
785 Lifetime: {lifetime.hoursSaved.toFixed(1)}h since{" "}
786 {lifetime.sinceCreatedAt.toISOString().slice(0, 10)}.
787 Constants are conservative on purpose — audit-friendly is
788 the brand.
789 </div>
790 {lifetimePills.length > 0 && (
791 <div style="margin-top:8px;font-size:11px">
792 All-time breakdown: {lifetimePills.join(" · ")}
793 </div>
794 )}
795 </div>
796 </details>
797 </div>
798 </div>
799 );
800};
801
7a14837Claude802/**
803 * Pure helper: pick the bottom-N repos by health score and return a
804 * prioritized "fix this next" plan. Health=0 repos (couldn't be
805 * computed) are excluded so the coach doesn't recommend ghost repos.
806 *
807 * Exported under __test for unit testing without touching the DB.
808 */
809export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
810 repoData: T[],
811 topN = 3
812): T[] {
813 return repoData
814 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
815 .sort((a, b) => a.healthScore - b.healthScore)
816 .slice(0, topN);
817}
818
819/** Module-scoped color picker for grade chips. Mirrors the inner
820 * `gradeColor` defined in the request handler scope, exposed at module
821 * level so HealthCoach (also module-scope) can reach it. */
822function moduleGradeColor(grade: string): string {
823 if (grade === "A+" || grade === "A") return "var(--green)";
824 if (grade === "B") return "#58a6ff";
825 if (grade === "C") return "var(--yellow)";
826 if (grade === "?") return "var(--text-muted)";
827 return "var(--red)";
828}
829
830const HealthCoach = ({
831 repoData,
832 username,
833}: {
834 repoData: Array<{
835 repo: { name: string; description: string | null };
836 healthScore: number;
837 healthGrade: string;
838 }>;
839 username: string;
840}) => {
841 const picks = pickRepoCoachPicks(repoData, 3);
842 if (picks.length === 0) {
843 return (
844 <div
845 class="card"
dc26881CC LABS App846 style="margin-bottom: var(--space-8); padding: var(--space-4); background: rgba(63,185,80,0.08); border-color: var(--green)"
7a14837Claude847 >
848 <h3 style="margin: 0 0 4px; font-size: 15px">
849 {"✨"} AI Health Coach
850 </h3>
851 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
852 All your repos are healthy (score &ge; 90). Nothing to triage.
853 </p>
854 </div>
855 );
856 }
857 return (
858 <div
859 class="card"
860 style="margin-bottom: 32px; padding: 0; overflow: hidden"
861 >
862 <div
dc26881CC LABS App863 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
7a14837Claude864 >
865 <div>
866 <h3 style="margin: 0; font-size: 15px">
867 {"✨"} AI Health Coach
868 </h3>
869 <p
dc26881CC LABS App870 style="margin: var(--space-1) 0 0; color: var(--text-muted); font-size: 12px"
7a14837Claude871 >
872 Top {picks.length} repos that would benefit from attention
873 this week.
874 </p>
875 </div>
876 </div>
877 <ul style="list-style: none; margin: 0; padding: 0">
878 {picks.map((p) => (
879 <li
dc26881CC LABS App880 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: var(--space-3)"
7a14837Claude881 >
882 <div
883 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
884 >
885 {p.healthGrade}
886 </div>
887 <div style="flex: 1; min-width: 0">
888 <a
889 href={`/${username}/${p.repo.name}`}
890 style="font-weight: 500"
891 >
892 {p.repo.name}
893 </a>
894 <div
895 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
896 >
897 Health score {p.healthScore}/100 — open the repo to see
898 breakdown + AI suggestions.
899 </div>
900 </div>
901 <a
902 href={`/${username}/${p.repo.name}/explain`}
903 class="btn"
904 style="font-size: 12px; padding: 4px 10px"
905 title="Run AI explain on this repo"
906 >
907 Coach me
908 </a>
909 </li>
910 ))}
911 </ul>
912 </div>
913 );
914};
915
f1ab587Claude916const StatBox = ({
917 label,
918 value,
919 color,
920}: {
921 label: string;
922 value: string;
923 color: string;
924}) => (
925 <div
dc26881CC LABS App926 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); text-align: center"
f1ab587Claude927 >
928 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
929 {value}
930 </div>
931 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
932 {label}
933 </div>
934 </div>
935);
936
937const ToggleSetting = ({
938 name,
939 label,
940 description,
941 defaultChecked,
942}: {
943 name: string;
944 label: string;
945 description: string;
946 defaultChecked: boolean;
947}) => (
948 <div
dc26881CC LABS App949 style="display: flex; justify-content: space-between; align-items: start; padding: var(--space-4) 0; border-bottom: 1px solid var(--border)"
f1ab587Claude950 >
951 <div style="flex: 1">
952 <div style="font-size: 15px; font-weight: 600">{label}</div>
953 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
954 {description}
955 </div>
956 </div>
957 <label class="toggle-switch">
2228c49copilot-swe-agent[bot]958 <input type="checkbox" name={name} value="on" checked={defaultChecked} aria-label={label} />
f1ab587Claude959 <span class="toggle-slider" />
960 </label>
961 </div>
962);
963
964const ActivityIcon = ({ action }: { action: string }) => {
965 const icons: Record<string, string> = {
966 push: "→",
967 issue_open: "\u25CB",
968 issue_close: "\u2713",
969 pr_open: "\u25CB",
970 pr_merge: "\u2B8C",
971 star: "\u2605",
972 fork: "\u2442",
973 comment: "\u{1F4AC}",
974 };
975 return (
976 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
977 {icons[action] || "•"}
978 </span>
979 );
980};
981
982function formatAction(action: string): string {
983 const labels: Record<string, string> = {
984 push: "Pushed code",
985 issue_open: "Opened issue",
986 issue_close: "Closed issue",
987 pr_open: "Opened pull request",
988 pr_merge: "Merged pull request",
989 star: "Starred",
990 fork: "Forked",
991 comment: "Commented",
992 };
993 return labels[action] || action;
994}
995
996function formatRelative(date: Date | string): string {
997 const d = typeof date === "string" ? new Date(date) : date;
998 const now = new Date();
999 const diffMs = now.getTime() - d.getTime();
1000 const diffMins = Math.floor(diffMs / 60000);
1001 if (diffMins < 1) return "just now";
1002 if (diffMins < 60) return `${diffMins}m ago`;
1003 const diffHours = Math.floor(diffMins / 60);
1004 if (diffHours < 24) return `${diffHours}h ago`;
1005 const diffDays = Math.floor(diffHours / 24);
1006 if (diffDays < 30) return `${diffDays}d ago`;
1007 return d.toLocaleDateString("en-US", {
1008 month: "short",
1009 day: "numeric",
1010 });
1011}
1012
1013export default dashboard;