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.tsxBlame934 lines · 2 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";
19import { db } from "../db";
20import {
21 repositories,
22 users,
23 activityFeed,
24 issues,
25 pullRequests,
26} from "../db/schema";
27import { Layout } from "../views/layout";
febd4f0Claude28import { LiveFeed } from "../views/live-feed";
f1ab587Claude29import { softAuth, requireAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
31import {
32 computeHealthScore,
33 detectCIConfig,
34} from "../lib/intelligence";
35import {
36 repoExists,
37 getDefaultBranch,
38 listCommits,
39 listBranches,
40} from "../git/repository";
46d6165Claude41import {
42 computeAiSavingsForUser,
43 computeLifetimeAiSavingsForUser,
44 type AiSavingsReport,
45 type AiSavingsLifetimeReport,
46} from "../lib/ai-hours-saved";
f1ab587Claude47
48const dashboard = new Hono<AuthEnv>();
49
50dashboard.use("*", softAuth);
51
52// ─── COMMAND CENTER ──────────────────────────────────────────
53
54dashboard.get("/dashboard", requireAuth, async (c) => {
55 const user = c.get("user")!;
56
57 // Get all user's repos
58 const repos = await db
59 .select()
60 .from(repositories)
61 .where(eq(repositories.ownerId, user.id))
62 .orderBy(desc(repositories.updatedAt));
63
64 // Compute health scores for all repos (in parallel)
65 const repoData = await Promise.all(
66 repos.map(async (repo) => {
67 let healthScore = 0;
68 let healthGrade = "?" as string;
69 let recentCommits = 0;
70 let branchCount = 0;
71 let ciConfig = null;
72
73 try {
74 if (await repoExists(user.username, repo.name)) {
75 const ref =
76 (await getDefaultBranch(user.username, repo.name)) || "main";
77 const [health, commits, branches, ci] = await Promise.all([
78 computeHealthScore(user.username, repo.name).catch(() => null),
79 listCommits(user.username, repo.name, ref, 5).catch(() => []),
80 listBranches(user.username, repo.name).catch(() => []),
81 detectCIConfig(user.username, repo.name, ref).catch(() => null),
82 ]);
83 if (health) {
84 healthScore = health.score;
85 healthGrade = health.grade;
86 }
87 recentCommits = commits.length;
88 branchCount = branches.length;
89 ciConfig = ci;
90 }
91 } catch {
92 // best effort
93 }
94
95 return {
96 repo,
97 healthScore,
98 healthGrade,
99 recentCommits,
100 branchCount,
101 ciConfig,
102 };
103 })
104 );
105
46d6165Claude106 // Block L9 — AI hours-saved counter. Pull both window + lifetime in
107 // parallel; both helpers swallow DB errors so the dashboard always renders.
108 const [savingsWeek, savingsLifetime] = await Promise.all([
109 computeAiSavingsForUser(user.id, { windowHours: 168 }),
110 computeLifetimeAiSavingsForUser(user.id),
111 ]);
112
f1ab587Claude113 // Get recent activity
114 let recentActivity: Array<{
115 action: string;
116 repoName: string;
117 metadata: string | null;
118 createdAt: Date;
119 }> = [];
120
121 try {
122 const repoIds = repos.map((r) => r.id);
123 if (repoIds.length > 0) {
124 const activity = await db
125 .select({
126 action: activityFeed.action,
127 metadata: activityFeed.metadata,
128 createdAt: activityFeed.createdAt,
129 repoId: activityFeed.repositoryId,
130 })
131 .from(activityFeed)
132 .where(eq(activityFeed.userId, user.id))
133 .orderBy(desc(activityFeed.createdAt))
134 .limit(20);
135
136 recentActivity = activity.map((a) => ({
137 action: a.action,
138 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
139 metadata: a.metadata,
140 createdAt: a.createdAt,
141 }));
142 }
143 } catch {
144 // DB not required for dashboard
145 }
146
147 const gradeColor = (grade: string) =>
148 grade === "A+" || grade === "A"
149 ? "var(--green)"
150 : grade === "B"
151 ? "#58a6ff"
152 : grade === "C"
153 ? "var(--yellow)"
154 : grade === "?"
155 ? "var(--text-muted)"
156 : "var(--red)";
157
158 return c.html(
159 <Layout title="Command Center" user={user}>
160 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px">
161 <div>
162 <h1 style="font-size: 28px; margin-bottom: 4px">Command Center</h1>
163 <p style="color: var(--text-muted); font-size: 14px">
164 Real-time overview of all your repositories
165 </p>
166 </div>
167 <div style="display: flex; gap: 8px">
168 <a href="/new" class="btn btn-primary">+ New repo</a>
169 <a href="/settings" class="btn">Settings</a>
170 </div>
171 </div>
172
46d6165Claude173 {/* ─── L9: AI hours-saved hero widget ─── */}
174 <AiHoursSavedWidget week={savingsWeek} lifetime={savingsLifetime} />
175
f1ab587Claude176 {/* ─── Stats Bar ─── */}
177 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 32px">
178 <StatBox
179 label="Repositories"
180 value={String(repos.length)}
181 color="var(--text-link)"
182 />
183 <StatBox
184 label="Avg Health"
185 value={
186 repos.length > 0
187 ? String(
188 Math.round(
189 repoData.reduce((s, r) => s + r.healthScore, 0) /
190 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
191 )
192 )
193 : "—"
194 }
195 color="var(--green)"
196 />
197 <StatBox
198 label="Total Stars"
199 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
200 color="var(--yellow)"
201 />
202 <StatBox
203 label="Open Issues"
204 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
205 color="var(--red)"
206 />
207 </div>
208
209 {/* ─── Repo Grid ─── */}
210 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
211 {repos.length === 0 ? (
80bed05Claude212 <div class="empty-state" style="text-align:left;padding:24px">
213 <div style="text-align:center;margin-bottom:20px">
214 <h2 style="margin-bottom:6px">Get started</h2>
215 <p style="color:var(--text-muted);font-size:14px;margin:0">
216 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
217 </p>
218 </div>
219 <div class="panel" style="margin-bottom:20px;text-align:left">
220 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
221 <div style="flex:1">
222 <div style="font-size:15px;font-weight:600">Create a new repository</div>
223 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
224 Start from scratch with green-ecosystem defaults.
225 </div>
226 </div>
227 <a href="/new" class="btn btn-primary">Create repo</a>
228 </div>
229 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
230 <div style="flex:1">
231 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
232 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
233 Mirror an existing repo — history, branches, tags.
234 </div>
235 </div>
236 <a href="/import" class="btn">Import repo</a>
237 </div>
238 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
239 <div style="flex:1">
240 <div style="font-size:15px;font-weight:600">Browse public repos</div>
241 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
242 See what others are building, fork what you like.
243 </div>
244 </div>
245 <a href="/explore" class="btn">Browse</a>
246 </div>
247 </div>
248 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px">
249 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
250 Push an existing project (preview)
251 </div>
252 <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.
253git remote add gluecron http://localhost:3000/{user.username}/&lt;your-repo&gt;.git
254git push -u gluecron main</code></pre>
255 </div>
f1ab587Claude256 </div>
257 ) : (
258 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; margin-bottom: 32px">
259 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
260 <div class="card" style="padding: 0; overflow: hidden">
261 {/* Health bar at top */}
262 <div
263 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
264 />
265 <div style="padding: 16px">
266 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
267 <div>
268 <h3 style="font-size: 16px; margin-bottom: 2px">
269 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
270 </h3>
271 {repo.description && (
272 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
273 {repo.description}
274 </p>
275 )}
276 </div>
277 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
278 <div
279 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
280 >
281 {healthGrade}
282 </div>
283 <div style="font-size: 10px; color: var(--text-muted)">
284 {healthScore}/100
285 </div>
286 </div>
287 </div>
288
289 <div style="display: flex; gap: 16px; font-size: 12px; color: var(--text-muted); margin-top: 8px">
290 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
291 <span>{"\u2606"} {repo.starCount}</span>
292 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
293 </div>
294
295 {ciConfig && ciConfig.commands.length > 0 && (
296 <div style="margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap">
297 {ciConfig.detected.slice(0, 3).map((d) => (
298 <span
299 class="badge"
300 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
301 >
302 {d}
303 </span>
304 ))}
305 </div>
306 )}
307
308 <div style="display: flex; gap: 6px; margin-top: 12px">
309 <a
310 href={`/${user.username}/${repo.name}/health`}
311 class="btn btn-sm"
312 style="font-size: 11px; padding: 2px 8px"
313 >
314 Health
315 </a>
316 <a
317 href={`/${user.username}/${repo.name}/dependencies`}
318 class="btn btn-sm"
319 style="font-size: 11px; padding: 2px 8px"
320 >
321 Deps
322 </a>
323 <a
324 href={`/${user.username}/${repo.name}/coupling`}
325 class="btn btn-sm"
326 style="font-size: 11px; padding: 2px 8px"
327 >
328 Insights
329 </a>
330 <a
331 href={`/${user.username}/${repo.name}/settings`}
332 class="btn btn-sm"
333 style="font-size: 11px; padding: 2px 8px"
334 >
335 Settings
336 </a>
337 </div>
338 </div>
339 </div>
340 ))}
341 </div>
342 )}
343
344 {/* ─── Activity Feed ─── */}
345 {recentActivity.length > 0 && (
346 <>
347 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
348 <div class="issue-list">
349 {recentActivity.map((a) => (
350 <div class="issue-item">
351 <div style="display: flex; gap: 8px; align-items: center">
352 <ActivityIcon action={a.action} />
353 <div>
354 <span style="font-size: 14px">
355 {formatAction(a.action)} in{" "}
356 <a href={`/${user.username}/${a.repoName}`}>
357 {a.repoName}
358 </a>
359 </span>
360 <div style="font-size: 12px; color: var(--text-muted)">
361 {formatRelative(a.createdAt)}
362 </div>
363 </div>
364 </div>
365 </div>
366 ))}
367 </div>
368 </>
369 )}
370
7a14837Claude371 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
372 <HealthCoach repoData={repoData} username={user.username} />
373
febd4f0Claude374 {/* ─── Live Activity (SSE) ─── */}
375 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
376
f1ab587Claude377 {/* ─── Quick Links ─── */}
378 <div style="margin-top: 32px; display: flex; gap: 16px; flex-wrap: wrap">
379 <a href="/explore" class="btn">Browse public repos</a>
380 <a href="/settings/tokens" class="btn">API tokens</a>
381 <a href="/settings/keys" class="btn">SSH keys</a>
382 </div>
383 </Layout>
384 );
385});
386
387// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
388
389dashboard.get(
390 "/:owner/:repo/settings/intelligence",
391 requireAuth,
392 async (c) => {
393 const { owner: ownerName, repo: repoName } = c.req.param();
394 const user = c.get("user")!;
395 const success = c.req.query("success");
396
397 return c.html(
398 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
399 <div style="max-width: 600px">
400 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
401 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
402 Control what gluecron does automatically when code is pushed to{" "}
403 <strong>{ownerName}/{repoName}</strong>.
404 </p>
405 {success && (
406 <div class="auth-success">{decodeURIComponent(success)}</div>
407 )}
408 <form
0316dbbClaude409 method="post"
f1ab587Claude410 action={`/${ownerName}/${repoName}/settings/intelligence`}
411 >
412 <ToggleSetting
413 name="auto_repair"
414 label="Auto-Repair"
415 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
416 defaultChecked={true}
417 />
418 <ToggleSetting
419 name="security_scan"
420 label="Security Scanning"
421 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
422 defaultChecked={true}
423 />
424 <ToggleSetting
425 name="health_score"
426 label="Health Score"
427 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
428 defaultChecked={true}
429 />
430 <ToggleSetting
431 name="push_analysis"
432 label="Push Risk Analysis"
433 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
434 defaultChecked={true}
435 />
436 <ToggleSetting
437 name="dep_analysis"
438 label="Dependency Analysis"
439 description="Build import graph, detect unused deps, find circular dependencies"
440 defaultChecked={true}
441 />
442 <ToggleSetting
443 name="gatetest"
444 label="GateTest Integration"
445 description="Send push events to GateTest for external security scanning"
446 defaultChecked={true}
447 />
448 <ToggleSetting
90fa787Claude449 name="deploy_webhook"
450 label="Auto-Deploy Webhook"
451 description="POST to your configured deploy webhook when pushing to the default branch"
f1ab587Claude452 defaultChecked={true}
453 />
454
455 <button
456 type="submit"
457 class="btn btn-primary"
458 style="margin-top: 12px"
459 >
460 Save settings
461 </button>
462 </form>
463 </div>
464 </Layout>
465 );
466 }
467);
468
469dashboard.post(
470 "/:owner/:repo/settings/intelligence",
471 requireAuth,
472 async (c) => {
473 const { owner: ownerName, repo: repoName } = c.req.param();
474 // In production, these would be saved to DB per-repo
475 // For now, acknowledge the settings
476 return c.redirect(
477 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
478 );
479 }
480);
481
482// ─── PUSH LOG ────────────────────────────────────────────────
483
484dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
485 const { owner, repo } = c.req.param();
486 const user = c.get("user");
487
488 if (!(await repoExists(owner, repo))) return c.notFound();
489 const ref = (await getDefaultBranch(owner, repo)) || "main";
490 const commits = await listCommits(owner, repo, ref, 30);
491
492 return c.html(
493 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
494 <div style="max-width: 900px">
495 <h2 style="margin-bottom: 4px">Push Log</h2>
496 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
497 Every push analyzed in real-time — risk scores, repairs, security alerts
498 </p>
499 <div class="issue-list">
500 {commits.map((commit) => {
501 // Determine if this was an auto-repair commit
502 const isRepair =
503 commit.author === "gluecron[bot]" ||
504 commit.message.includes("auto-repair");
505 const isRollback = commit.message.startsWith("revert: rollback");
506
507 return (
508 <div class="issue-item" style="flex-direction: column; align-items: stretch">
509 <div style="display: flex; justify-content: space-between; align-items: start">
510 <div style="display: flex; gap: 8px; align-items: start">
511 {isRepair ? (
512 <span
513 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
514 title="Auto-repair"
515 >
516 {"⚡"}
517 </span>
518 ) : isRollback ? (
519 <span
520 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
521 title="Rollback"
522 >
523 {"↩"}
524 </span>
525 ) : (
526 <span
527 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
528 >
529 {"→"}
530 </span>
531 )}
532 <div>
533 <a
534 href={`/${owner}/${repo}/commit/${commit.sha}`}
535 style="font-weight: 600; font-size: 14px"
536 >
537 {commit.message}
538 </a>
539 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
540 {commit.author} —{" "}
541 {new Date(commit.date).toLocaleString("en-US", {
542 month: "short",
543 day: "numeric",
544 hour: "2-digit",
545 minute: "2-digit",
546 })}
547 </div>
548 </div>
549 </div>
550 <a
551 href={`/${owner}/${repo}/commit/${commit.sha}`}
552 class="commit-sha"
553 >
554 {commit.sha.slice(0, 7)}
555 </a>
556 </div>
557 {isRepair && (
558 <div
559 style="margin-top: 8px; padding: 8px 12px; background: rgba(63, 185, 80, 0.1); border-radius: var(--radius); font-size: 12px; color: var(--green)"
560 >
561 Automatically repaired by gluecron
562 </div>
563 )}
564 </div>
565 );
566 })}
567 </div>
568 </div>
569 </Layout>
570 );
571});
572
573// ─── COMPONENTS ──────────────────────────────────────────────
574
46d6165Claude575/**
576 * Block L9 — pure formatter used by the dashboard widget AND tests.
577 * Turns the breakdown into the small stat-pill array shown under the
578 * big number. Exported so the markup contract is testable without
579 * importing JSX.
580 */
581export function formatSavingsPills(b: {
582 prsAutoMerged: number;
583 issuesBuiltByAi: number;
584 aiReviewsPosted: number;
585 aiTriagesPosted: number;
586 aiCommitMsgs: number;
587 secretsAutoRepaired: number;
588 gateAutoRepairs: number;
589}): string[] {
590 const pills: string[] = [];
591 if (b.prsAutoMerged) pills.push(`${b.prsAutoMerged} PR${b.prsAutoMerged === 1 ? "" : "s"} auto-merged`);
592 if (b.issuesBuiltByAi) pills.push(`${b.issuesBuiltByAi} issue${b.issuesBuiltByAi === 1 ? "" : "s"} built`);
593 if (b.aiReviewsPosted) pills.push(`${b.aiReviewsPosted} AI review${b.aiReviewsPosted === 1 ? "" : "s"}`);
594 if (b.aiTriagesPosted) pills.push(`${b.aiTriagesPosted} triage${b.aiTriagesPosted === 1 ? "" : "s"}`);
595 const fixes = b.secretsAutoRepaired + b.gateAutoRepairs;
596 if (fixes) pills.push(`${fixes} auto-fix${fixes === 1 ? "" : "es"}`);
597 if (b.aiCommitMsgs) pills.push(`${b.aiCommitMsgs} commit msg${b.aiCommitMsgs === 1 ? "" : "s"}`);
598 return pills;
599}
600
601const AiHoursSavedWidget = ({
602 week,
603 lifetime,
604}: {
605 week: AiSavingsReport;
606 lifetime: AiSavingsLifetimeReport;
607}) => {
608 const weekPills = formatSavingsPills(week.breakdown);
609 const lifetimePills = formatSavingsPills(lifetime.breakdown);
610 const hasAnyWeek = week.hoursSaved > 0 || weekPills.length > 0;
611 return (
612 <div
613 class="card ai-hours-saved-widget"
614 style="margin-bottom: 24px; padding: 0; overflow: hidden; position: relative; background: var(--accent-gradient-faint, var(--bg-secondary)); border-color: var(--accent)"
615 >
616 <div style="padding: 24px 24px 20px 24px">
617 <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap">
618 <div style="flex:1;min-width:240px">
619 <div style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px">
620 AI working for you
621 </div>
622 <div
623 data-testid="ai-hours-saved-this-week"
624 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"
625 >
626 {week.hoursSaved.toFixed(1)}h
627 </div>
628 <div style="margin-top: 6px; font-size: 14px; color: var(--text-muted)">
629 Claude saved you{" "}
630 <strong style="color: var(--text)">
631 {week.hoursSaved.toFixed(1)} hours
632 </strong>{" "}
633 this week.
634 {lifetime.hoursSaved > week.hoursSaved && (
635 <span>
636 {" — "}
637 <strong style="color: var(--text)">
638 {lifetime.hoursSaved.toFixed(1)}h
639 </strong>{" "}
640 all-time.
641 </span>
642 )}
643 </div>
644 </div>
645 <div
646 class="ai-hours-saved-tabs"
647 style="display:flex;gap:4px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;padding:2px"
648 >
649 <span
650 data-tab="this-week"
651 style="padding:4px 10px;font-size:12px;font-weight:600;border-radius:4px;background:var(--bg);color:var(--text)"
652 >
653 This week
654 </span>
655 <span
656 data-tab="all-time"
657 style="padding:4px 10px;font-size:12px;color:var(--text-muted)"
658 >
659 All-time
660 </span>
661 </div>
662 </div>
663
664 {hasAnyWeek ? (
665 <div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:16px">
666 {weekPills.map((p) => (
667 <span
668 class="badge"
669 style="font-size:12px;padding:4px 10px;background:rgba(140,109,255,0.10);border-color:var(--accent);color:var(--text)"
670 >
671 {p}
672 </span>
673 ))}
674 </div>
675 ) : (
676 <div style="margin-top:16px;font-size:13px;color:var(--text-muted)">
677 No AI activity this week yet — open a PR, label an issue{" "}
678 <code>ai:build</code>, or let auto-merge sweep your branches.
679 The counter will start climbing.
680 </div>
681 )}
682
683 <details style="margin-top:16px">
684 <summary
685 data-testid="ai-hours-saved-formula-toggle"
686 style="cursor:pointer;font-size:12px;color:var(--text-muted);user-select:none"
687 >
688 How is this calculated?
689 </summary>
690 <div
691 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"
692 >
693 <div>hoursSaved =</div>
694 <div>&nbsp;&nbsp;{week.breakdown.prsAutoMerged} PRs auto-merged × 0.30</div>
695 <div>+ {week.breakdown.issuesBuiltByAi} issues built by AI × 1.50</div>
696 <div>+ {week.breakdown.aiReviewsPosted} AI reviews × 0.25</div>
697 <div>+ {week.breakdown.aiTriagesPosted} AI triages × 0.10</div>
698 <div>+ {week.breakdown.aiCommitMsgs} AI commit msgs × 0.05</div>
699 <div>+ {week.breakdown.secretsAutoRepaired} secrets repaired × 0.50</div>
700 <div>+ {week.breakdown.gateAutoRepairs} gates repaired × 0.40</div>
701 <div style="margin-top:6px;color:var(--text)">
702 = {week.hoursSaved.toFixed(1)}h (this week,{" "}
703 {week.windowHours}h window)
704 </div>
705 <div style="margin-top:8px;font-size:11px">
706 Lifetime: {lifetime.hoursSaved.toFixed(1)}h since{" "}
707 {lifetime.sinceCreatedAt.toISOString().slice(0, 10)}.
708 Constants are conservative on purpose — audit-friendly is
709 the brand.
710 </div>
711 {lifetimePills.length > 0 && (
712 <div style="margin-top:8px;font-size:11px">
713 All-time breakdown: {lifetimePills.join(" · ")}
714 </div>
715 )}
716 </div>
717 </details>
718 </div>
719 </div>
720 );
721};
722
7a14837Claude723/**
724 * Pure helper: pick the bottom-N repos by health score and return a
725 * prioritized "fix this next" plan. Health=0 repos (couldn't be
726 * computed) are excluded so the coach doesn't recommend ghost repos.
727 *
728 * Exported under __test for unit testing without touching the DB.
729 */
730export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
731 repoData: T[],
732 topN = 3
733): T[] {
734 return repoData
735 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
736 .sort((a, b) => a.healthScore - b.healthScore)
737 .slice(0, topN);
738}
739
740/** Module-scoped color picker for grade chips. Mirrors the inner
741 * `gradeColor` defined in the request handler scope, exposed at module
742 * level so HealthCoach (also module-scope) can reach it. */
743function moduleGradeColor(grade: string): string {
744 if (grade === "A+" || grade === "A") return "var(--green)";
745 if (grade === "B") return "#58a6ff";
746 if (grade === "C") return "var(--yellow)";
747 if (grade === "?") return "var(--text-muted)";
748 return "var(--red)";
749}
750
751const HealthCoach = ({
752 repoData,
753 username,
754}: {
755 repoData: Array<{
756 repo: { name: string; description: string | null };
757 healthScore: number;
758 healthGrade: string;
759 }>;
760 username: string;
761}) => {
762 const picks = pickRepoCoachPicks(repoData, 3);
763 if (picks.length === 0) {
764 return (
765 <div
766 class="card"
767 style="margin-bottom: 32px; padding: 16px; background: rgba(63,185,80,0.08); border-color: var(--green)"
768 >
769 <h3 style="margin: 0 0 4px; font-size: 15px">
770 {"✨"} AI Health Coach
771 </h3>
772 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
773 All your repos are healthy (score &ge; 90). Nothing to triage.
774 </p>
775 </div>
776 );
777 }
778 return (
779 <div
780 class="card"
781 style="margin-bottom: 32px; padding: 0; overflow: hidden"
782 >
783 <div
784 style="padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
785 >
786 <div>
787 <h3 style="margin: 0; font-size: 15px">
788 {"✨"} AI Health Coach
789 </h3>
790 <p
791 style="margin: 4px 0 0; color: var(--text-muted); font-size: 12px"
792 >
793 Top {picks.length} repos that would benefit from attention
794 this week.
795 </p>
796 </div>
797 </div>
798 <ul style="list-style: none; margin: 0; padding: 0">
799 {picks.map((p) => (
800 <li
801 style="padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px"
802 >
803 <div
804 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
805 >
806 {p.healthGrade}
807 </div>
808 <div style="flex: 1; min-width: 0">
809 <a
810 href={`/${username}/${p.repo.name}`}
811 style="font-weight: 500"
812 >
813 {p.repo.name}
814 </a>
815 <div
816 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
817 >
818 Health score {p.healthScore}/100 — open the repo to see
819 breakdown + AI suggestions.
820 </div>
821 </div>
822 <a
823 href={`/${username}/${p.repo.name}/explain`}
824 class="btn"
825 style="font-size: 12px; padding: 4px 10px"
826 title="Run AI explain on this repo"
827 >
828 Coach me
829 </a>
830 </li>
831 ))}
832 </ul>
833 </div>
834 );
835};
836
f1ab587Claude837const StatBox = ({
838 label,
839 value,
840 color,
841}: {
842 label: string;
843 value: string;
844 color: string;
845}) => (
846 <div
847 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; text-align: center"
848 >
849 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
850 {value}
851 </div>
852 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
853 {label}
854 </div>
855 </div>
856);
857
858const ToggleSetting = ({
859 name,
860 label,
861 description,
862 defaultChecked,
863}: {
864 name: string;
865 label: string;
866 description: string;
867 defaultChecked: boolean;
868}) => (
869 <div
870 style="display: flex; justify-content: space-between; align-items: start; padding: 16px 0; border-bottom: 1px solid var(--border)"
871 >
872 <div style="flex: 1">
873 <div style="font-size: 15px; font-weight: 600">{label}</div>
874 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
875 {description}
876 </div>
877 </div>
878 <label class="toggle-switch">
2228c49copilot-swe-agent[bot]879 <input type="checkbox" name={name} value="on" checked={defaultChecked} aria-label={label} />
f1ab587Claude880 <span class="toggle-slider" />
881 </label>
882 </div>
883);
884
885const ActivityIcon = ({ action }: { action: string }) => {
886 const icons: Record<string, string> = {
887 push: "→",
888 issue_open: "\u25CB",
889 issue_close: "\u2713",
890 pr_open: "\u25CB",
891 pr_merge: "\u2B8C",
892 star: "\u2605",
893 fork: "\u2442",
894 comment: "\u{1F4AC}",
895 };
896 return (
897 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
898 {icons[action] || "•"}
899 </span>
900 );
901};
902
903function formatAction(action: string): string {
904 const labels: Record<string, string> = {
905 push: "Pushed code",
906 issue_open: "Opened issue",
907 issue_close: "Closed issue",
908 pr_open: "Opened pull request",
909 pr_merge: "Merged pull request",
910 star: "Starred",
911 fork: "Forked",
912 comment: "Commented",
913 };
914 return labels[action] || action;
915}
916
917function formatRelative(date: Date | string): string {
918 const d = typeof date === "string" ? new Date(date) : date;
919 const now = new Date();
920 const diffMs = now.getTime() - d.getTime();
921 const diffMins = Math.floor(diffMs / 60000);
922 if (diffMins < 1) return "just now";
923 if (diffMins < 60) return `${diffMins}m ago`;
924 const diffHours = Math.floor(diffMins / 60);
925 if (diffHours < 24) return `${diffHours}h ago`;
926 const diffDays = Math.floor(diffHours / 24);
927 if (diffDays < 30) return `${diffDays}d ago`;
928 return d.toLocaleDateString("en-US", {
929 month: "short",
930 day: "numeric",
931 });
932}
933
934export default dashboard;