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

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.tsxBlame1006 lines · 3 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
bba3681Test User184 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>
200 ) : (
201 <span>Verify your email to keep using Gluecron.</span>
202 )}
203 </div>
204 <form
205 method="post"
206 action="/verify-email/resend"
bba3681Test User207 style="display: inline-flex; gap: var(--space-2); align-items: center; margin: 0"
c63b860Claude208 >
209 <input
210 type="hidden"
211 name="_csrf"
212 value={(c.get("csrfToken") as string | undefined) || ""}
213 />
214 <button
215 type="submit"
216 class="btn"
217 style="padding: 4px 10px; font-size: 12px"
218 >
219 Resend verification link
220 </button>
221 <a
222 href="/dashboard?p2_dismiss=1"
223 class="btn"
224 style="padding: 4px 10px; font-size: 12px"
225 aria-label="Dismiss verification banner"
226 >
227 Dismiss
228 </a>
229 </form>
230 </div>
231 )}
f1ab587Claude232 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px">
233 <div>
234 <h1 style="font-size: 28px; margin-bottom: 4px">Command Center</h1>
235 <p style="color: var(--text-muted); font-size: 14px">
236 Real-time overview of all your repositories
237 </p>
238 </div>
bba3681Test User239 <div style="display: flex; gap: var(--space-2)">
f1ab587Claude240 <a href="/new" class="btn btn-primary">+ New repo</a>
241 <a href="/settings" class="btn">Settings</a>
242 </div>
243 </div>
244
46d6165Claude245 {/* ─── L9: AI hours-saved hero widget ─── */}
246 <AiHoursSavedWidget week={savingsWeek} lifetime={savingsLifetime} />
247
f1ab587Claude248 {/* ─── Stats Bar ─── */}
bba3681Test User249 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8)">
f1ab587Claude250 <StatBox
251 label="Repositories"
252 value={String(repos.length)}
253 color="var(--text-link)"
254 />
255 <StatBox
256 label="Avg Health"
257 value={
258 repos.length > 0
259 ? String(
260 Math.round(
261 repoData.reduce((s, r) => s + r.healthScore, 0) /
262 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
263 )
264 )
265 : "—"
266 }
267 color="var(--green)"
268 />
269 <StatBox
270 label="Total Stars"
271 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
272 color="var(--yellow)"
273 />
274 <StatBox
275 label="Open Issues"
276 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
277 color="var(--red)"
278 />
279 </div>
280
281 {/* ─── Repo Grid ─── */}
282 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
283 {repos.length === 0 ? (
bba3681Test User284 <div class="empty-state" style="text-align:left;padding:var(--space-6)">
80bed05Claude285 <div style="text-align:center;margin-bottom:20px">
286 <h2 style="margin-bottom:6px">Get started</h2>
287 <p style="color:var(--text-muted);font-size:14px;margin:0">
288 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
289 </p>
290 </div>
291 <div class="panel" style="margin-bottom:20px;text-align:left">
bba3681Test User292 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude293 <div style="flex:1">
294 <div style="font-size:15px;font-weight:600">Create a new repository</div>
295 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
296 Start from scratch with green-ecosystem defaults.
297 </div>
298 </div>
299 <a href="/new" class="btn btn-primary">Create repo</a>
300 </div>
bba3681Test User301 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude302 <div style="flex:1">
303 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
304 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
305 Mirror an existing repo — history, branches, tags.
306 </div>
307 </div>
308 <a href="/import" class="btn">Import repo</a>
309 </div>
bba3681Test User310 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude311 <div style="flex:1">
312 <div style="font-size:15px;font-weight:600">Browse public repos</div>
313 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
314 See what others are building, fork what you like.
315 </div>
316 </div>
317 <a href="/explore" class="btn">Browse</a>
318 </div>
319 </div>
bba3681Test User320 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-4)">
80bed05Claude321 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
322 Push an existing project (preview)
323 </div>
324 <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.
325git remote add gluecron http://localhost:3000/{user.username}/&lt;your-repo&gt;.git
326git push -u gluecron main</code></pre>
327 </div>
f1ab587Claude328 </div>
329 ) : (
bba3681Test User330 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8)">
f1ab587Claude331 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
332 <div class="card" style="padding: 0; overflow: hidden">
333 {/* Health bar at top */}
334 <div
335 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
336 />
bba3681Test User337 <div style="padding: var(--space-4)">
f1ab587Claude338 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
339 <div>
340 <h3 style="font-size: 16px; margin-bottom: 2px">
341 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
342 </h3>
343 {repo.description && (
344 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
345 {repo.description}
346 </p>
347 )}
348 </div>
349 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
350 <div
351 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
352 >
353 {healthGrade}
354 </div>
355 <div style="font-size: 10px; color: var(--text-muted)">
356 {healthScore}/100
357 </div>
358 </div>
359 </div>
360
bba3681Test User361 <div style="display: flex; gap: var(--space-4); font-size: 12px; color: var(--text-muted); margin-top: var(--space-2)">
f1ab587Claude362 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
363 <span>{"\u2606"} {repo.starCount}</span>
364 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
365 </div>
366
367 {ciConfig && ciConfig.commands.length > 0 && (
bba3681Test User368 <div style="margin-top: var(--space-2); display: flex; gap: 6px; flex-wrap: wrap">
f1ab587Claude369 {ciConfig.detected.slice(0, 3).map((d) => (
370 <span
371 class="badge"
372 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
373 >
374 {d}
375 </span>
376 ))}
377 </div>
378 )}
379
bba3681Test User380 <div style="display: flex; gap: 6px; margin-top: var(--space-3)">
f1ab587Claude381 <a
382 href={`/${user.username}/${repo.name}/health`}
383 class="btn btn-sm"
384 style="font-size: 11px; padding: 2px 8px"
385 >
386 Health
387 </a>
388 <a
389 href={`/${user.username}/${repo.name}/dependencies`}
390 class="btn btn-sm"
391 style="font-size: 11px; padding: 2px 8px"
392 >
393 Deps
394 </a>
395 <a
396 href={`/${user.username}/${repo.name}/coupling`}
397 class="btn btn-sm"
398 style="font-size: 11px; padding: 2px 8px"
399 >
400 Insights
401 </a>
402 <a
403 href={`/${user.username}/${repo.name}/settings`}
404 class="btn btn-sm"
405 style="font-size: 11px; padding: 2px 8px"
406 >
407 Settings
408 </a>
409 </div>
410 </div>
411 </div>
412 ))}
413 </div>
414 )}
415
416 {/* ─── Activity Feed ─── */}
417 {recentActivity.length > 0 && (
418 <>
419 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
420 <div class="issue-list">
421 {recentActivity.map((a) => (
422 <div class="issue-item">
bba3681Test User423 <div style="display: flex; gap: var(--space-2); align-items: center">
f1ab587Claude424 <ActivityIcon action={a.action} />
425 <div>
426 <span style="font-size: 14px">
427 {formatAction(a.action)} in{" "}
428 <a href={`/${user.username}/${a.repoName}`}>
429 {a.repoName}
430 </a>
431 </span>
432 <div style="font-size: 12px; color: var(--text-muted)">
433 {formatRelative(a.createdAt)}
434 </div>
435 </div>
436 </div>
437 </div>
438 ))}
439 </div>
440 </>
441 )}
442
7a14837Claude443 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
444 <HealthCoach repoData={repoData} username={user.username} />
445
febd4f0Claude446 {/* ─── Live Activity (SSE) ─── */}
447 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
448
f1ab587Claude449 {/* ─── Quick Links ─── */}
bba3681Test User450 <div style="margin-top: var(--space-8); display: flex; gap: var(--space-4); flex-wrap: wrap">
f1ab587Claude451 <a href="/explore" class="btn">Browse public repos</a>
452 <a href="/settings/tokens" class="btn">API tokens</a>
453 <a href="/settings/keys" class="btn">SSH keys</a>
454 </div>
455 </Layout>
456 );
457});
458
459// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
460
461dashboard.get(
462 "/:owner/:repo/settings/intelligence",
463 requireAuth,
464 async (c) => {
465 const { owner: ownerName, repo: repoName } = c.req.param();
466 const user = c.get("user")!;
467 const success = c.req.query("success");
468
469 return c.html(
470 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
471 <div style="max-width: 600px">
472 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
473 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
474 Control what gluecron does automatically when code is pushed to{" "}
475 <strong>{ownerName}/{repoName}</strong>.
476 </p>
477 {success && (
478 <div class="auth-success">{decodeURIComponent(success)}</div>
479 )}
480 <form
0316dbbClaude481 method="post"
f1ab587Claude482 action={`/${ownerName}/${repoName}/settings/intelligence`}
483 >
484 <ToggleSetting
485 name="auto_repair"
486 label="Auto-Repair"
487 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
488 defaultChecked={true}
489 />
490 <ToggleSetting
491 name="security_scan"
492 label="Security Scanning"
493 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
494 defaultChecked={true}
495 />
496 <ToggleSetting
497 name="health_score"
498 label="Health Score"
499 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
500 defaultChecked={true}
501 />
502 <ToggleSetting
503 name="push_analysis"
504 label="Push Risk Analysis"
505 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
506 defaultChecked={true}
507 />
508 <ToggleSetting
509 name="dep_analysis"
510 label="Dependency Analysis"
511 description="Build import graph, detect unused deps, find circular dependencies"
512 defaultChecked={true}
513 />
514 <ToggleSetting
515 name="gatetest"
516 label="GateTest Integration"
517 description="Send push events to GateTest for external security scanning"
518 defaultChecked={true}
519 />
520 <ToggleSetting
90fa787Claude521 name="deploy_webhook"
522 label="Auto-Deploy Webhook"
523 description="POST to your configured deploy webhook when pushing to the default branch"
f1ab587Claude524 defaultChecked={true}
525 />
526
527 <button
528 type="submit"
529 class="btn btn-primary"
530 style="margin-top: 12px"
531 >
532 Save settings
533 </button>
534 </form>
535 </div>
536 </Layout>
537 );
538 }
539);
540
541dashboard.post(
542 "/:owner/:repo/settings/intelligence",
543 requireAuth,
544 async (c) => {
545 const { owner: ownerName, repo: repoName } = c.req.param();
546 // In production, these would be saved to DB per-repo
547 // For now, acknowledge the settings
548 return c.redirect(
549 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
550 );
551 }
552);
553
554// ─── PUSH LOG ────────────────────────────────────────────────
555
556dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
557 const { owner, repo } = c.req.param();
558 const user = c.get("user");
559
560 if (!(await repoExists(owner, repo))) return c.notFound();
561 const ref = (await getDefaultBranch(owner, repo)) || "main";
562 const commits = await listCommits(owner, repo, ref, 30);
563
564 return c.html(
565 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
566 <div style="max-width: 900px">
567 <h2 style="margin-bottom: 4px">Push Log</h2>
568 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
569 Every push analyzed in real-time — risk scores, repairs, security alerts
570 </p>
571 <div class="issue-list">
572 {commits.map((commit) => {
573 // Determine if this was an auto-repair commit
574 const isRepair =
575 commit.author === "gluecron[bot]" ||
576 commit.message.includes("auto-repair");
577 const isRollback = commit.message.startsWith("revert: rollback");
578
579 return (
580 <div class="issue-item" style="flex-direction: column; align-items: stretch">
581 <div style="display: flex; justify-content: space-between; align-items: start">
bba3681Test User582 <div style="display: flex; gap: var(--space-2); align-items: start">
f1ab587Claude583 {isRepair ? (
584 <span
585 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
586 title="Auto-repair"
587 >
588 {"⚡"}
589 </span>
590 ) : isRollback ? (
591 <span
592 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
593 title="Rollback"
594 >
595 {"↩"}
596 </span>
597 ) : (
598 <span
599 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
600 >
601 {"→"}
602 </span>
603 )}
604 <div>
605 <a
606 href={`/${owner}/${repo}/commit/${commit.sha}`}
607 style="font-weight: 600; font-size: 14px"
608 >
609 {commit.message}
610 </a>
611 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
612 {commit.author} —{" "}
613 {new Date(commit.date).toLocaleString("en-US", {
614 month: "short",
615 day: "numeric",
616 hour: "2-digit",
617 minute: "2-digit",
618 })}
619 </div>
620 </div>
621 </div>
622 <a
623 href={`/${owner}/${repo}/commit/${commit.sha}`}
624 class="commit-sha"
625 >
626 {commit.sha.slice(0, 7)}
627 </a>
628 </div>
629 {isRepair && (
630 <div
bba3681Test User631 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)"
f1ab587Claude632 >
633 Automatically repaired by gluecron
634 </div>
635 )}
636 </div>
637 );
638 })}
639 </div>
640 </div>
641 </Layout>
642 );
643});
644
645// ─── COMPONENTS ──────────────────────────────────────────────
646
46d6165Claude647/**
648 * Block L9 — pure formatter used by the dashboard widget AND tests.
649 * Turns the breakdown into the small stat-pill array shown under the
650 * big number. Exported so the markup contract is testable without
651 * importing JSX.
652 */
653export function formatSavingsPills(b: {
654 prsAutoMerged: number;
655 issuesBuiltByAi: number;
656 aiReviewsPosted: number;
657 aiTriagesPosted: number;
658 aiCommitMsgs: number;
659 secretsAutoRepaired: number;
660 gateAutoRepairs: number;
661}): string[] {
662 const pills: string[] = [];
663 if (b.prsAutoMerged) pills.push(`${b.prsAutoMerged} PR${b.prsAutoMerged === 1 ? "" : "s"} auto-merged`);
664 if (b.issuesBuiltByAi) pills.push(`${b.issuesBuiltByAi} issue${b.issuesBuiltByAi === 1 ? "" : "s"} built`);
665 if (b.aiReviewsPosted) pills.push(`${b.aiReviewsPosted} AI review${b.aiReviewsPosted === 1 ? "" : "s"}`);
666 if (b.aiTriagesPosted) pills.push(`${b.aiTriagesPosted} triage${b.aiTriagesPosted === 1 ? "" : "s"}`);
667 const fixes = b.secretsAutoRepaired + b.gateAutoRepairs;
668 if (fixes) pills.push(`${fixes} auto-fix${fixes === 1 ? "" : "es"}`);
669 if (b.aiCommitMsgs) pills.push(`${b.aiCommitMsgs} commit msg${b.aiCommitMsgs === 1 ? "" : "s"}`);
670 return pills;
671}
672
673const AiHoursSavedWidget = ({
674 week,
675 lifetime,
676}: {
677 week: AiSavingsReport;
678 lifetime: AiSavingsLifetimeReport;
679}) => {
680 const weekPills = formatSavingsPills(week.breakdown);
681 const lifetimePills = formatSavingsPills(lifetime.breakdown);
682 const hasAnyWeek = week.hoursSaved > 0 || weekPills.length > 0;
683 return (
684 <div
685 class="card ai-hours-saved-widget"
686 style="margin-bottom: 24px; padding: 0; overflow: hidden; position: relative; background: var(--accent-gradient-faint, var(--bg-secondary)); border-color: var(--accent)"
687 >
bba3681Test User688 <div style="padding: var(--space-6) var(--space-6) var(--space-5) var(--space-6)">
689 <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-4);flex-wrap:wrap">
46d6165Claude690 <div style="flex:1;min-width:240px">
691 <div style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px">
692 AI working for you
693 </div>
694 <div
695 data-testid="ai-hours-saved-this-week"
696 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"
697 >
698 {week.hoursSaved.toFixed(1)}h
699 </div>
700 <div style="margin-top: 6px; font-size: 14px; color: var(--text-muted)">
701 Claude saved you{" "}
702 <strong style="color: var(--text)">
703 {week.hoursSaved.toFixed(1)} hours
704 </strong>{" "}
705 this week.
706 {lifetime.hoursSaved > week.hoursSaved && (
707 <span>
708 {" — "}
709 <strong style="color: var(--text)">
710 {lifetime.hoursSaved.toFixed(1)}h
711 </strong>{" "}
712 all-time.
713 </span>
714 )}
715 </div>
716 </div>
717 <div
718 class="ai-hours-saved-tabs"
719 style="display:flex;gap:4px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;padding:2px"
720 >
721 <span
722 data-tab="this-week"
723 style="padding:4px 10px;font-size:12px;font-weight:600;border-radius:4px;background:var(--bg);color:var(--text)"
724 >
725 This week
726 </span>
727 <span
728 data-tab="all-time"
729 style="padding:4px 10px;font-size:12px;color:var(--text-muted)"
730 >
731 All-time
732 </span>
733 </div>
734 </div>
735
736 {hasAnyWeek ? (
bba3681Test User737 <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-4)">
46d6165Claude738 {weekPills.map((p) => (
739 <span
740 class="badge"
741 style="font-size:12px;padding:4px 10px;background:rgba(140,109,255,0.10);border-color:var(--accent);color:var(--text)"
742 >
743 {p}
744 </span>
745 ))}
746 </div>
747 ) : (
748 <div style="margin-top:16px;font-size:13px;color:var(--text-muted)">
749 No AI activity this week yet — open a PR, label an issue{" "}
750 <code>ai:build</code>, or let auto-merge sweep your branches.
751 The counter will start climbing.
752 </div>
753 )}
754
755 <details style="margin-top:16px">
756 <summary
757 data-testid="ai-hours-saved-formula-toggle"
758 style="cursor:pointer;font-size:12px;color:var(--text-muted);user-select:none"
759 >
760 How is this calculated?
761 </summary>
762 <div
763 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"
764 >
765 <div>hoursSaved =</div>
766 <div>&nbsp;&nbsp;{week.breakdown.prsAutoMerged} PRs auto-merged × 0.30</div>
767 <div>+ {week.breakdown.issuesBuiltByAi} issues built by AI × 1.50</div>
768 <div>+ {week.breakdown.aiReviewsPosted} AI reviews × 0.25</div>
769 <div>+ {week.breakdown.aiTriagesPosted} AI triages × 0.10</div>
770 <div>+ {week.breakdown.aiCommitMsgs} AI commit msgs × 0.05</div>
771 <div>+ {week.breakdown.secretsAutoRepaired} secrets repaired × 0.50</div>
772 <div>+ {week.breakdown.gateAutoRepairs} gates repaired × 0.40</div>
773 <div style="margin-top:6px;color:var(--text)">
774 = {week.hoursSaved.toFixed(1)}h (this week,{" "}
775 {week.windowHours}h window)
776 </div>
777 <div style="margin-top:8px;font-size:11px">
778 Lifetime: {lifetime.hoursSaved.toFixed(1)}h since{" "}
779 {lifetime.sinceCreatedAt.toISOString().slice(0, 10)}.
780 Constants are conservative on purpose — audit-friendly is
781 the brand.
782 </div>
783 {lifetimePills.length > 0 && (
784 <div style="margin-top:8px;font-size:11px">
785 All-time breakdown: {lifetimePills.join(" · ")}
786 </div>
787 )}
788 </div>
789 </details>
790 </div>
791 </div>
792 );
793};
794
7a14837Claude795/**
796 * Pure helper: pick the bottom-N repos by health score and return a
797 * prioritized "fix this next" plan. Health=0 repos (couldn't be
798 * computed) are excluded so the coach doesn't recommend ghost repos.
799 *
800 * Exported under __test for unit testing without touching the DB.
801 */
802export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
803 repoData: T[],
804 topN = 3
805): T[] {
806 return repoData
807 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
808 .sort((a, b) => a.healthScore - b.healthScore)
809 .slice(0, topN);
810}
811
812/** Module-scoped color picker for grade chips. Mirrors the inner
813 * `gradeColor` defined in the request handler scope, exposed at module
814 * level so HealthCoach (also module-scope) can reach it. */
815function moduleGradeColor(grade: string): string {
816 if (grade === "A+" || grade === "A") return "var(--green)";
817 if (grade === "B") return "#58a6ff";
818 if (grade === "C") return "var(--yellow)";
819 if (grade === "?") return "var(--text-muted)";
820 return "var(--red)";
821}
822
823const HealthCoach = ({
824 repoData,
825 username,
826}: {
827 repoData: Array<{
828 repo: { name: string; description: string | null };
829 healthScore: number;
830 healthGrade: string;
831 }>;
832 username: string;
833}) => {
834 const picks = pickRepoCoachPicks(repoData, 3);
835 if (picks.length === 0) {
836 return (
837 <div
838 class="card"
bba3681Test User839 style="margin-bottom: var(--space-8); padding: var(--space-4); background: rgba(63,185,80,0.08); border-color: var(--green)"
7a14837Claude840 >
841 <h3 style="margin: 0 0 4px; font-size: 15px">
842 {"✨"} AI Health Coach
843 </h3>
844 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
845 All your repos are healthy (score &ge; 90). Nothing to triage.
846 </p>
847 </div>
848 );
849 }
850 return (
851 <div
852 class="card"
853 style="margin-bottom: 32px; padding: 0; overflow: hidden"
854 >
855 <div
bba3681Test User856 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
7a14837Claude857 >
858 <div>
859 <h3 style="margin: 0; font-size: 15px">
860 {"✨"} AI Health Coach
861 </h3>
862 <p
bba3681Test User863 style="margin: var(--space-1) 0 0; color: var(--text-muted); font-size: 12px"
7a14837Claude864 >
865 Top {picks.length} repos that would benefit from attention
866 this week.
867 </p>
868 </div>
869 </div>
870 <ul style="list-style: none; margin: 0; padding: 0">
871 {picks.map((p) => (
872 <li
bba3681Test User873 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: var(--space-3)"
7a14837Claude874 >
875 <div
876 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
877 >
878 {p.healthGrade}
879 </div>
880 <div style="flex: 1; min-width: 0">
881 <a
882 href={`/${username}/${p.repo.name}`}
883 style="font-weight: 500"
884 >
885 {p.repo.name}
886 </a>
887 <div
888 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
889 >
890 Health score {p.healthScore}/100 — open the repo to see
891 breakdown + AI suggestions.
892 </div>
893 </div>
894 <a
895 href={`/${username}/${p.repo.name}/explain`}
896 class="btn"
897 style="font-size: 12px; padding: 4px 10px"
898 title="Run AI explain on this repo"
899 >
900 Coach me
901 </a>
902 </li>
903 ))}
904 </ul>
905 </div>
906 );
907};
908
f1ab587Claude909const StatBox = ({
910 label,
911 value,
912 color,
913}: {
914 label: string;
915 value: string;
916 color: string;
917}) => (
918 <div
bba3681Test User919 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); text-align: center"
f1ab587Claude920 >
921 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
922 {value}
923 </div>
924 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
925 {label}
926 </div>
927 </div>
928);
929
930const ToggleSetting = ({
931 name,
932 label,
933 description,
934 defaultChecked,
935}: {
936 name: string;
937 label: string;
938 description: string;
939 defaultChecked: boolean;
940}) => (
941 <div
bba3681Test User942 style="display: flex; justify-content: space-between; align-items: start; padding: var(--space-4) 0; border-bottom: 1px solid var(--border)"
f1ab587Claude943 >
944 <div style="flex: 1">
945 <div style="font-size: 15px; font-weight: 600">{label}</div>
946 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
947 {description}
948 </div>
949 </div>
950 <label class="toggle-switch">
2228c49copilot-swe-agent[bot]951 <input type="checkbox" name={name} value="on" checked={defaultChecked} aria-label={label} />
f1ab587Claude952 <span class="toggle-slider" />
953 </label>
954 </div>
955);
956
957const ActivityIcon = ({ action }: { action: string }) => {
958 const icons: Record<string, string> = {
959 push: "→",
960 issue_open: "\u25CB",
961 issue_close: "\u2713",
962 pr_open: "\u25CB",
963 pr_merge: "\u2B8C",
964 star: "\u2605",
965 fork: "\u2442",
966 comment: "\u{1F4AC}",
967 };
968 return (
969 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
970 {icons[action] || "•"}
971 </span>
972 );
973};
974
975function formatAction(action: string): string {
976 const labels: Record<string, string> = {
977 push: "Pushed code",
978 issue_open: "Opened issue",
979 issue_close: "Closed issue",
980 pr_open: "Opened pull request",
981 pr_merge: "Merged pull request",
982 star: "Starred",
983 fork: "Forked",
984 comment: "Commented",
985 };
986 return labels[action] || action;
987}
988
989function formatRelative(date: Date | string): string {
990 const d = typeof date === "string" ? new Date(date) : date;
991 const now = new Date();
992 const diffMs = now.getTime() - d.getTime();
993 const diffMins = Math.floor(diffMs / 60000);
994 if (diffMins < 1) return "just now";
995 if (diffMins < 60) return `${diffMins}m ago`;
996 const diffHours = Math.floor(diffMins / 60);
997 if (diffHours < 24) return `${diffHours}h ago`;
998 const diffDays = Math.floor(diffHours / 24);
999 if (diffDays < 30) return `${diffDays}d ago`;
1000 return d.toLocaleDateString("en-US", {
1001 month: "short",
1002 day: "numeric",
1003 });
1004}
1005
1006export default dashboard;