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.tsxBlame1920 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";
2d78948Claude18import { eq, desc, and, inArray, ne, sql, gte } from "drizzle-orm";
c63b860Claude19import { getCookie, setCookie } from "hono/cookie";
f1ab587Claude20import { db } from "../db";
21import {
22 repositories,
23 users,
24 activityFeed,
2d78948Claude25 auditLog,
26 gateRuns,
f1ab587Claude27 issues,
28 pullRequests,
29} from "../db/schema";
30import { Layout } from "../views/layout";
febd4f0Claude31import { LiveFeed } from "../views/live-feed";
f1ab587Claude32import { softAuth, requireAuth } from "../middleware/auth";
33import type { AuthEnv } from "../middleware/auth";
34import {
35 computeHealthScore,
36 detectCIConfig,
37} from "../lib/intelligence";
38import {
39 repoExists,
40 getDefaultBranch,
41 listCommits,
42 listBranches,
43} from "../git/repository";
46d6165Claude44import {
45 computeAiSavingsForUser,
46 computeLifetimeAiSavingsForUser,
47 type AiSavingsReport,
48 type AiSavingsLifetimeReport,
49} from "../lib/ai-hours-saved";
f1ab587Claude50
2d78948Claude51// ─── AI Activity — Last Hour ─────────────────────────────────────────────────
52
53const SECRET_GATE_NAMES_DASH = [
54 "Secret scan",
55 "Secret Scan",
56 "Security scan",
57 "Security Scan",
58];
59
60export type AiActivityItem = {
61 kind: "pr_auto_merged" | "spec_shipped" | "ci_healed" | "secret_repaired";
62 repoName: string;
63 repoOwner: string;
64 /** PR or issue number — null when it's a gate repair with no PR */
65 refNumber: number | null;
66 /** Short title for display (PR/issue title or gate name) */
67 label: string;
68 occurredAt: Date;
69};
70
71export type AiActivityReport = {
72 items: AiActivityItem[];
73 prsAutoMerged: number;
74 specsShipped: number;
75 ciHealed: number;
76 secretsRepaired: number;
77};
78
79/**
80 * Fetch autopilot-driven activity from the last 60 minutes for all repos
81 * owned by the given user. Never throws — on any DB error returns an
82 * empty report so the widget always renders.
83 */
84async function fetchAiActivityLastHour(
85 userId: string,
86 repoIds: string[],
87 repoMap: Map<string, { name: string; ownerUsername: string }>,
88): Promise<AiActivityReport> {
89 const empty: AiActivityReport = {
90 items: [],
91 prsAutoMerged: 0,
92 specsShipped: 0,
93 ciHealed: 0,
94 secretsRepaired: 0,
95 };
96 if (repoIds.length === 0) return empty;
97
98 const since = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
99
100 try {
101 // ── 1. Auto-merged PRs ─────────────────────────────────────
102 const autoMergeRows = await db
103 .select({
104 repositoryId: auditLog.repositoryId,
105 targetId: auditLog.targetId,
106 createdAt: auditLog.createdAt,
107 })
108 .from(auditLog)
109 .where(
110 and(
111 eq(auditLog.action, "auto_merge.merged"),
112 inArray(auditLog.repositoryId, repoIds as [string, ...string[]]),
113 gte(auditLog.createdAt, since),
114 )
115 )
116 .orderBy(desc(auditLog.createdAt))
117 .limit(20);
118
119 // Fetch PR titles for auto-merged rows
120 const autoMergePrIds = autoMergeRows
121 .map((r) => r.targetId)
122 .filter(Boolean) as string[];
123 const prTitleMap = new Map<string, { number: number; title: string }>();
124 if (autoMergePrIds.length > 0) {
125 const prRows = await db
126 .select({ id: pullRequests.id, number: pullRequests.number, title: pullRequests.title })
127 .from(pullRequests)
128 .where(inArray(pullRequests.id, autoMergePrIds as [string, ...string[]]));
129 for (const r of prRows) prTitleMap.set(r.id, { number: r.number, title: r.title });
130 }
131
132 // ── 2. AI-built specs dispatched ───────────────────────────
133 const specRows = await db
134 .select({
135 repositoryId: auditLog.repositoryId,
136 targetId: auditLog.targetId,
137 createdAt: auditLog.createdAt,
138 })
139 .from(auditLog)
140 .where(
141 and(
142 eq(auditLog.action, "ai_build.dispatched"),
143 inArray(auditLog.repositoryId, repoIds as [string, ...string[]]),
144 gte(auditLog.createdAt, since),
145 )
146 )
147 .orderBy(desc(auditLog.createdAt))
148 .limit(20);
149
150 // Fetch issue numbers/titles for dispatched specs
151 const specIssueIds = specRows
152 .map((r) => r.targetId)
153 .filter(Boolean) as string[];
154 const issueTitleMap = new Map<string, { number: number; title: string }>();
155 if (specIssueIds.length > 0) {
156 const issueRows = await db
157 .select({ id: issues.id, number: issues.number, title: issues.title })
158 .from(issues)
159 .where(inArray(issues.id, specIssueIds as [string, ...string[]]));
160 for (const r of issueRows) issueTitleMap.set(r.id, { number: r.number, title: r.title });
161 }
162
163 // ── 3. Gate auto-repairs (secrets) ─────────────────────────
164 const secretRepairRows = await db
165 .select({
166 repositoryId: gateRuns.repositoryId,
167 gateName: gateRuns.gateName,
168 createdAt: gateRuns.createdAt,
169 })
170 .from(gateRuns)
171 .where(
172 and(
173 eq(gateRuns.status, "repaired"),
174 inArray(gateRuns.repositoryId, repoIds as [string, ...string[]]),
175 gte(gateRuns.createdAt, since),
176 inArray(gateRuns.gateName, SECRET_GATE_NAMES_DASH as [string, ...string[]]),
177 )
178 )
179 .orderBy(desc(gateRuns.createdAt))
180 .limit(20);
181
182 // ── 4. Gate auto-repairs (CI / other) ──────────────────────
183 const ciHealRows = await db
184 .select({
185 repositoryId: gateRuns.repositoryId,
186 gateName: gateRuns.gateName,
187 createdAt: gateRuns.createdAt,
188 })
189 .from(gateRuns)
190 .where(
191 and(
192 eq(gateRuns.status, "repaired"),
193 inArray(gateRuns.repositoryId, repoIds as [string, ...string[]]),
194 gte(gateRuns.createdAt, since),
195 sql`${gateRuns.gateName} NOT IN ('Secret scan','Secret Scan','Security scan','Security Scan')`,
196 )
197 )
198 .orderBy(desc(gateRuns.createdAt))
199 .limit(20);
200
201 // ── Build item list ────────────────────────────────────────
202 const items: AiActivityItem[] = [];
203
204 for (const r of autoMergeRows) {
205 const repo = repoMap.get(r.repositoryId ?? "");
206 if (!repo) continue;
207 const pr = r.targetId ? prTitleMap.get(r.targetId) : undefined;
208 items.push({
209 kind: "pr_auto_merged",
210 repoName: repo.name,
211 repoOwner: repo.ownerUsername,
212 refNumber: pr?.number ?? null,
213 label: pr?.title ?? "Pull request",
214 occurredAt: r.createdAt,
215 });
216 }
217
218 for (const r of specRows) {
219 const repo = repoMap.get(r.repositoryId ?? "");
220 if (!repo) continue;
221 const issue = r.targetId ? issueTitleMap.get(r.targetId) : undefined;
222 items.push({
223 kind: "spec_shipped",
224 repoName: repo.name,
225 repoOwner: repo.ownerUsername,
226 refNumber: issue?.number ?? null,
227 label: issue?.title ?? "Issue",
228 occurredAt: r.createdAt,
229 });
230 }
231
232 for (const r of secretRepairRows) {
233 const repo = repoMap.get(r.repositoryId ?? "");
234 if (!repo) continue;
235 items.push({
236 kind: "secret_repaired",
237 repoName: repo.name,
238 repoOwner: repo.ownerUsername,
239 refNumber: null,
240 label: r.gateName,
241 occurredAt: r.createdAt,
242 });
243 }
244
245 for (const r of ciHealRows) {
246 const repo = repoMap.get(r.repositoryId ?? "");
247 if (!repo) continue;
248 items.push({
249 kind: "ci_healed",
250 repoName: repo.name,
251 repoOwner: repo.ownerUsername,
252 refNumber: null,
253 label: r.gateName,
254 occurredAt: r.createdAt,
255 });
256 }
257
258 // Sort newest first
259 items.sort((a, b) => b.occurredAt.getTime() - a.occurredAt.getTime());
260
261 return {
262 items,
263 prsAutoMerged: autoMergeRows.length,
264 specsShipped: specRows.length,
265 ciHealed: ciHealRows.length,
266 secretsRepaired: secretRepairRows.length,
267 };
268 } catch (err) {
269 console.error("[dashboard] ai-activity-last-hour degraded:", err);
270 return empty;
271 }
272}
273
f1ab587Claude274const dashboard = new Hono<AuthEnv>();
275
276dashboard.use("*", softAuth);
277
278// ─── COMMAND CENTER ──────────────────────────────────────────
279
280dashboard.get("/dashboard", requireAuth, async (c) => {
281 const user = c.get("user")!;
282
c63b860Claude283 // Block P2 — banner dismiss handler. Set a session cookie and re-redirect
284 // to the bare /dashboard URL so refreshing doesn't keep firing the dismiss.
285 if (c.req.query("p2_dismiss") === "1") {
286 setCookie(c, "p2_verify_dismissed", "1", {
287 path: "/",
288 httpOnly: true,
289 sameSite: "Lax",
290 });
291 return c.redirect("/dashboard");
292 }
293
f1dc7c7Claude294 // ── Loading skeleton (flag-gated) ──
295 // Render an SSR'd structural preview when `?skeleton=1` is present.
296 // Keeps the user oriented on first paint while DB warms up. Behind a
297 // flag until we wire it to streamed/replaced content — we don't ship
298 // a flash before the real markup lands.
299 if (c.req.query("skeleton") === "1") {
300 return c.html(
301 <Layout title="Command Center" user={user}>
302 <DashboardSkeleton />
303 </Layout>
304 );
305 }
306
f1ab587Claude307 // Get all user's repos
308 const repos = await db
309 .select()
310 .from(repositories)
311 .where(eq(repositories.ownerId, user.id))
312 .orderBy(desc(repositories.updatedAt));
313
314 // Compute health scores for all repos (in parallel)
315 const repoData = await Promise.all(
316 repos.map(async (repo) => {
317 let healthScore = 0;
318 let healthGrade = "?" as string;
319 let recentCommits = 0;
320 let branchCount = 0;
321 let ciConfig = null;
322
323 try {
324 if (await repoExists(user.username, repo.name)) {
325 const ref =
326 (await getDefaultBranch(user.username, repo.name)) || "main";
327 const [health, commits, branches, ci] = await Promise.all([
328 computeHealthScore(user.username, repo.name).catch(() => null),
329 listCommits(user.username, repo.name, ref, 5).catch(() => []),
330 listBranches(user.username, repo.name).catch(() => []),
331 detectCIConfig(user.username, repo.name, ref).catch(() => null),
332 ]);
333 if (health) {
334 healthScore = health.score;
335 healthGrade = health.grade;
336 }
337 recentCommits = commits.length;
338 branchCount = branches.length;
339 ciConfig = ci;
340 }
341 } catch {
342 // best effort
343 }
344
345 return {
346 repo,
347 healthScore,
348 healthGrade,
349 recentCommits,
350 branchCount,
351 ciConfig,
352 };
353 })
354 );
355
46d6165Claude356 // Block L9 — AI hours-saved counter. Pull both window + lifetime in
357 // parallel; both helpers swallow DB errors so the dashboard always renders.
2d78948Claude358 // Also fetch the last-hour autopilot activity for the new widget.
359 const repoIds = repos.map((r) => r.id);
360 const repoMap = new Map(
361 repos.map((r) => [r.id, { name: r.name, ownerUsername: user.username }])
362 );
363 const [savingsWeek, savingsLifetime, aiActivity] = await Promise.all([
46d6165Claude364 computeAiSavingsForUser(user.id, { windowHours: 168 }),
365 computeLifetimeAiSavingsForUser(user.id),
2d78948Claude366 fetchAiActivityLastHour(user.id, repoIds, repoMap),
46d6165Claude367 ]);
368
f1ab587Claude369 // Get recent activity
370 let recentActivity: Array<{
371 action: string;
372 repoName: string;
373 metadata: string | null;
374 createdAt: Date;
375 }> = [];
376
377 try {
378 const repoIds = repos.map((r) => r.id);
379 if (repoIds.length > 0) {
380 const activity = await db
381 .select({
382 action: activityFeed.action,
383 metadata: activityFeed.metadata,
384 createdAt: activityFeed.createdAt,
385 repoId: activityFeed.repositoryId,
386 })
387 .from(activityFeed)
388 .where(eq(activityFeed.userId, user.id))
389 .orderBy(desc(activityFeed.createdAt))
390 .limit(20);
391
392 recentActivity = activity.map((a) => ({
393 action: a.action,
394 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
395 metadata: a.metadata,
396 createdAt: a.createdAt,
397 }));
398 }
399 } catch {
400 // DB not required for dashboard
401 }
402
920812bClaude403 // Review queue — open non-draft PRs in user's repos, authored by others
404 let reviewQueuePrs: Array<{
405 prNumber: number;
406 prTitle: string;
407 repoName: string;
408 createdAt: Date;
409 }> = [];
410 // Open PRs the user authored that are still open (anywhere on the platform)
411 let myOpenPrs: Array<{
412 prNumber: number;
413 prTitle: string;
414 repoName: string;
415 ownerUsername: string;
416 createdAt: Date;
417 }> = [];
418 try {
419 const repoIds = repos.map((r) => r.id);
420 const prQueries: Promise<any>[] = [];
421 if (repoIds.length > 0) {
422 prQueries.push(
423 db
424 .select({
425 prNumber: pullRequests.number,
426 prTitle: pullRequests.title,
427 repoName: repositories.name,
428 createdAt: pullRequests.createdAt,
429 })
430 .from(pullRequests)
431 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
432 .where(
433 and(
434 inArray(pullRequests.repositoryId, repoIds),
435 eq(pullRequests.state, "open"),
436 ne(pullRequests.authorId, user.id),
437 eq(pullRequests.isDraft, false),
438 )
439 )
440 .orderBy(desc(pullRequests.createdAt))
441 .limit(6)
442 );
443 } else {
444 prQueries.push(Promise.resolve([]));
445 }
446 prQueries.push(
447 db
448 .select({
449 prNumber: pullRequests.number,
450 prTitle: pullRequests.title,
451 repoName: repositories.name,
452 ownerUsername: users.username,
453 createdAt: pullRequests.createdAt,
454 })
455 .from(pullRequests)
456 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
457 .innerJoin(users, eq(repositories.ownerId, users.id))
458 .where(
459 and(
460 eq(pullRequests.authorId, user.id),
461 eq(pullRequests.state, "open"),
462 )
463 )
464 .orderBy(desc(pullRequests.updatedAt))
465 .limit(6)
466 );
467 const [queueRows, myPrRows] = await Promise.all(prQueries);
468 reviewQueuePrs = queueRows;
469 myOpenPrs = myPrRows;
470 } catch { /* non-blocking */ }
471
f1ab587Claude472 const gradeColor = (grade: string) =>
473 grade === "A+" || grade === "A"
474 ? "var(--green)"
475 : grade === "B"
476 ? "#58a6ff"
477 : grade === "C"
478 ? "var(--yellow)"
479 : grade === "?"
480 ? "var(--text-muted)"
481 : "var(--red)";
482
c63b860Claude483 // Block P2 — email verification banner. Shows when the user hasn't
484 // verified yet AND they haven't dismissed it this session. Also surfaces
485 // transient resend feedback (`?verify=sent` / `?verify=rate_limited`)
486 // and the post-register hint (`?welcome=1`).
487 const verifyDismissed = getCookie(c, "p2_verify_dismissed") === "1";
488 const showVerifyBanner =
489 !(user as any).emailVerifiedAt && !verifyDismissed;
490 const verifyQuery = c.req.query("verify");
491 const welcomeQuery = c.req.query("welcome");
492
f1ab587Claude493 return c.html(
494 <Layout title="Command Center" user={user}>
c63b860Claude495 {showVerifyBanner && (
496 <div
dc26881CC LABS App497 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"
c63b860Claude498 data-p2-verify-banner=""
499 >
500 <div style="flex: 1 1 auto; min-width: 0">
501 {welcomeQuery === "1" ? (
502 <span>
503 Welcome to Gluecron! Check your inbox to verify your email.
504 </span>
505 ) : verifyQuery === "sent" ? (
506 <span>
507 Verification link sent. It may take a minute to arrive.
508 </span>
509 ) : verifyQuery === "rate_limited" ? (
510 <span>
511 You've requested too many verification emails. Try again later.
512 </span>
826eccfTest User513 ) : verifyQuery === "not_configured" ? (
514 <span>
515 Email delivery isn't configured on this instance yet — your
516 site admin needs to set <code>EMAIL_PROVIDER=resend</code> and{" "}
517 <code>RESEND_API_KEY</code>. Until then the verification link
518 is written to the server log.
519 </span>
c63b860Claude520 ) : (
521 <span>Verify your email to keep using Gluecron.</span>
522 )}
523 </div>
524 <form
525 method="post"
526 action="/verify-email/resend"
dc26881CC LABS App527 style="display: inline-flex; gap: var(--space-2); align-items: center; margin: 0"
c63b860Claude528 >
529 <input
530 type="hidden"
531 name="_csrf"
532 value={(c.get("csrfToken") as string | undefined) || ""}
533 />
534 <button
535 type="submit"
536 class="btn"
537 style="padding: 4px 10px; font-size: 12px"
538 >
539 Resend verification link
540 </button>
541 <a
542 href="/dashboard?p2_dismiss=1"
543 class="btn"
544 style="padding: 4px 10px; font-size: 12px"
545 aria-label="Dismiss verification banner"
546 >
547 Dismiss
548 </a>
549 </form>
550 </div>
551 )}
a004c46Claude552 <div class="dash-hero">
553 <div class="dash-hero-bg" aria-hidden="true">
554 <div class="dash-hero-orb" />
f1ab587Claude555 </div>
a004c46Claude556 <div class="dash-hero-inner">
557 <div class="dash-hero-text">
558 <div class="dash-hero-eyebrow">
559 {(() => {
560 const hour = new Date().getHours();
561 if (hour < 5) return "Late night,";
562 if (hour < 12) return "Good morning,";
563 if (hour < 17) return "Good afternoon,";
564 if (hour < 21) return "Good evening,";
565 return "Late night,";
566 })()}{" "}
567 <span class="dash-hero-username">{user.username}</span>
568 </div>
569 <h1 class="dash-hero-title">
570 Your{" "}
571 <span class="gradient-text">command center</span>.
572 </h1>
573 <p class="dash-hero-sub">
574 {repos.length === 0
575 ? "Create your first repository to start shipping with AI."
576 : `${repos.length} repo${repos.length === 1 ? "" : "s"} · real-time health, AI activity, and gate status across everything you own.`}
577 </p>
578 </div>
579 <div class="dash-hero-actions">
580 <a href="/new" class="btn btn-primary">+ New repo</a>
581 <a href="/import" class="btn">Import from GitHub</a>
582 <a href="/settings" class="btn">Settings</a>
583 </div>
f1ab587Claude584 </div>
585 </div>
a004c46Claude586 <style
587 dangerouslySetInnerHTML={{
588 __html: `
589 .dash-hero {
590 position: relative;
591 margin-bottom: var(--space-6);
592 padding: var(--space-5) var(--space-6) var(--space-5);
593 background: var(--bg-elevated);
594 border: 1px solid var(--border);
595 border-radius: 16px;
596 overflow: hidden;
597 }
598 .dash-hero::before {
599 content: '';
600 position: absolute;
601 top: 0; left: 0; right: 0;
602 height: 2px;
603 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
604 opacity: 0.7;
605 pointer-events: none;
606 }
607 .dash-hero-bg {
608 position: absolute;
609 inset: -20% -10% auto auto;
610 width: 380px;
611 height: 380px;
612 pointer-events: none;
613 z-index: 0;
614 }
615 .dash-hero-orb {
616 position: absolute;
617 inset: 0;
618 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
619 filter: blur(80px);
620 opacity: 0.7;
621 animation: dashHeroOrb 14s ease-in-out infinite;
622 }
623 @keyframes dashHeroOrb {
624 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
625 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
626 }
627 @media (prefers-reduced-motion: reduce) {
628 .dash-hero-orb { animation: none; }
629 }
630 .dash-hero-inner {
631 position: relative;
632 z-index: 1;
633 display: flex;
634 justify-content: space-between;
635 align-items: flex-end;
636 gap: var(--space-4);
637 flex-wrap: wrap;
638 }
639 .dash-hero-text { flex: 1; min-width: 280px; }
640 .dash-hero-eyebrow {
641 font-size: 13px;
642 color: var(--text-muted);
643 margin-bottom: var(--space-2);
644 letter-spacing: -0.005em;
645 }
646 .dash-hero-username {
647 color: var(--accent);
648 font-weight: 600;
649 }
650 .dash-hero-title {
651 font-size: clamp(28px, 4vw, 40px);
652 font-family: var(--font-display);
653 font-weight: 800;
654 letter-spacing: -0.028em;
655 line-height: 1.05;
656 margin: 0 0 var(--space-2);
657 color: var(--text-strong);
658 }
659 .dash-hero-title .gradient-text {
660 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
661 -webkit-background-clip: text;
662 background-clip: text;
663 -webkit-text-fill-color: transparent;
664 color: transparent;
665 }
666 .dash-hero-sub {
667 font-size: 15px;
668 color: var(--text-muted);
669 margin: 0;
670 line-height: 1.5;
671 max-width: 580px;
672 }
673 .dash-hero-actions {
674 display: flex;
675 gap: var(--space-2);
676 flex-wrap: wrap;
677 }
678 @media (max-width: 720px) {
679 .dash-hero-inner { flex-direction: column; align-items: flex-start; }
680 .dash-hero-actions { width: 100%; }
f1dc7c7Claude681 .dash-hero-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
682 .dash-hero { padding: var(--space-4); }
683 .dash-hero-text { min-width: 0; }
684 .dash-hero-bg { width: 220px; height: 220px; inset: -10% -20% auto auto; }
685 .ai-hours-saved-tabs { flex-wrap: wrap; }
a004c46Claude686 }
687 `,
688 }}
689 />
f1ab587Claude690
46d6165Claude691 {/* ─── L9: AI hours-saved hero widget ─── */}
692 <AiHoursSavedWidget week={savingsWeek} lifetime={savingsLifetime} />
693
2d78948Claude694 {/* ─── AI Activity — Last Hour ─── */}
695 <AiActivityWidget activity={aiActivity} username={user.username} />
696
c6018a5Claude697 {/* ─── Quick actions — surfaces the 5 most-leverage AI features so
698 they're discoverable from the dashboard without diving into the
699 AI dropdown in the top nav. Scoped CSS under .qa- prefix. ─── */}
700 <QuickActionsPanel firstRepo={repos[0]} username={user.username} />
701
f1ab587Claude702 {/* ─── Stats Bar ─── */}
dc26881CC LABS App703 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8)">
f1ab587Claude704 <StatBox
705 label="Repositories"
706 value={String(repos.length)}
707 color="var(--text-link)"
708 />
709 <StatBox
710 label="Avg Health"
711 value={
712 repos.length > 0
713 ? String(
714 Math.round(
715 repoData.reduce((s, r) => s + r.healthScore, 0) /
716 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
717 )
718 )
719 : "—"
720 }
721 color="var(--green)"
722 />
723 <StatBox
724 label="Total Stars"
725 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
726 color="var(--yellow)"
727 />
728 <StatBox
729 label="Open Issues"
730 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
731 color="var(--red)"
732 />
733 </div>
734
735 {/* ─── Repo Grid ─── */}
736 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
737 {repos.length === 0 ? (
dc26881CC LABS App738 <div class="empty-state" style="text-align:left;padding:var(--space-6)">
80bed05Claude739 <div style="text-align:center;margin-bottom:20px">
740 <h2 style="margin-bottom:6px">Get started</h2>
741 <p style="color:var(--text-muted);font-size:14px;margin:0">
742 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
743 </p>
744 </div>
745 <div class="panel" style="margin-bottom:20px;text-align:left">
dc26881CC LABS App746 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude747 <div style="flex:1">
748 <div style="font-size:15px;font-weight:600">Create a new repository</div>
749 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
750 Start from scratch with green-ecosystem defaults.
751 </div>
752 </div>
753 <a href="/new" class="btn btn-primary">Create repo</a>
754 </div>
dc26881CC LABS App755 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude756 <div style="flex:1">
757 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
758 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
759 Mirror an existing repo — history, branches, tags.
760 </div>
761 </div>
762 <a href="/import" class="btn">Import repo</a>
763 </div>
dc26881CC LABS App764 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude765 <div style="flex:1">
766 <div style="font-size:15px;font-weight:600">Browse public repos</div>
767 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
768 See what others are building, fork what you like.
769 </div>
770 </div>
771 <a href="/explore" class="btn">Browse</a>
772 </div>
773 </div>
dc26881CC LABS App774 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-4)">
80bed05Claude775 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
776 Push an existing project (preview)
777 </div>
778 <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.
779git remote add gluecron http://localhost:3000/{user.username}/&lt;your-repo&gt;.git
780git push -u gluecron main</code></pre>
781 </div>
f1ab587Claude782 </div>
783 ) : (
dc26881CC LABS App784 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8)">
f1ab587Claude785 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
786 <div class="card" style="padding: 0; overflow: hidden">
787 {/* Health bar at top */}
788 <div
789 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
790 />
dc26881CC LABS App791 <div style="padding: var(--space-4)">
f1ab587Claude792 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
793 <div>
794 <h3 style="font-size: 16px; margin-bottom: 2px">
795 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
796 </h3>
797 {repo.description && (
798 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
799 {repo.description}
800 </p>
801 )}
802 </div>
803 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
804 <div
805 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
806 >
807 {healthGrade}
808 </div>
809 <div style="font-size: 10px; color: var(--text-muted)">
810 {healthScore}/100
811 </div>
812 </div>
813 </div>
814
dc26881CC LABS App815 <div style="display: flex; gap: var(--space-4); font-size: 12px; color: var(--text-muted); margin-top: var(--space-2)">
f1ab587Claude816 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
817 <span>{"\u2606"} {repo.starCount}</span>
818 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
819 </div>
820
821 {ciConfig && ciConfig.commands.length > 0 && (
dc26881CC LABS App822 <div style="margin-top: var(--space-2); display: flex; gap: 6px; flex-wrap: wrap">
f1ab587Claude823 {ciConfig.detected.slice(0, 3).map((d) => (
824 <span
825 class="badge"
826 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
827 >
828 {d}
829 </span>
830 ))}
831 </div>
832 )}
833
dc26881CC LABS App834 <div style="display: flex; gap: 6px; margin-top: var(--space-3)">
f1ab587Claude835 <a
836 href={`/${user.username}/${repo.name}/health`}
837 class="btn btn-sm"
838 style="font-size: 11px; padding: 2px 8px"
839 >
840 Health
841 </a>
842 <a
843 href={`/${user.username}/${repo.name}/dependencies`}
844 class="btn btn-sm"
845 style="font-size: 11px; padding: 2px 8px"
846 >
847 Deps
848 </a>
849 <a
850 href={`/${user.username}/${repo.name}/coupling`}
851 class="btn btn-sm"
852 style="font-size: 11px; padding: 2px 8px"
853 >
854 Insights
855 </a>
856 <a
857 href={`/${user.username}/${repo.name}/settings`}
858 class="btn btn-sm"
859 style="font-size: 11px; padding: 2px 8px"
860 >
861 Settings
862 </a>
863 </div>
864 </div>
865 </div>
866 ))}
867 </div>
868 )}
869
870 {/* ─── Activity Feed ─── */}
871 {recentActivity.length > 0 && (
872 <>
873 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
874 <div class="issue-list">
875 {recentActivity.map((a) => (
876 <div class="issue-item">
dc26881CC LABS App877 <div style="display: flex; gap: var(--space-2); align-items: center">
f1ab587Claude878 <ActivityIcon action={a.action} />
879 <div>
880 <span style="font-size: 14px">
881 {formatAction(a.action)} in{" "}
882 <a href={`/${user.username}/${a.repoName}`}>
883 {a.repoName}
884 </a>
885 </span>
886 <div style="font-size: 12px; color: var(--text-muted)">
887 {formatRelative(a.createdAt)}
888 </div>
889 </div>
890 </div>
891 </div>
892 ))}
893 </div>
894 </>
895 )}
896
7a14837Claude897 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
898 <HealthCoach repoData={repoData} username={user.username} />
899
febd4f0Claude900 {/* ─── Live Activity (SSE) ─── */}
901 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
902
920812bClaude903 {/* ─── Review queue + My open PRs ─── */}
904 {(reviewQueuePrs.length > 0 || myOpenPrs.length > 0) && (
905 <>
906 <style dangerouslySetInnerHTML={{ __html: `
907 .dash-rq { border:1px solid var(--border); border-radius:12px; overflow:hidden; }
908 .dash-rq-head { display:flex; align-items:center; justify-content:space-between; padding:11px 16px; background:var(--bg-elevated); border-bottom:1px solid var(--border); font-size:13px; font-weight:600; }
909 .dash-rq-head-count { font-size:11px; font-weight:700; padding:1px 7px; border-radius:9999px; background:var(--bg-tertiary); color:var(--text-muted); }
910 .dash-rq-row { display:flex; align-items:center; gap:10px; padding:9px 16px; border-bottom:1px solid var(--border); font-size:13px; text-decoration:none; color:inherit; }
911 .dash-rq-row:last-child { border-bottom:none; }
912 .dash-rq-row:hover { background:var(--bg-hover); }
913 .dash-rq-repo { font-size:11px; color:var(--text-muted); flex:0 0 auto; }
914 .dash-rq-title { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
915 .dash-rq-age { font-size:11px; color:var(--text-muted); flex:0 0 auto; }
916 .dash-rq-empty { padding:24px; text-align:center; color:var(--text-muted); font-size:13px; }
917 ` }} />
918 <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:var(--space-4);margin-top:var(--space-8)">
919 {reviewQueuePrs.length > 0 && (
920 <div class="dash-rq">
921 <div class="dash-rq-head">
922 <span>{"⏳"} Needs your review</span>
923 <span class="dash-rq-head-count">{reviewQueuePrs.length}</span>
924 </div>
925 {reviewQueuePrs.map((pr) => (
926 <a
927 href={`/${user.username}/${pr.repoName}/pulls/${pr.prNumber}`}
928 class="dash-rq-row"
929 >
930 <span class="dash-rq-repo">{pr.repoName}</span>
931 <span class="dash-rq-title" title={pr.prTitle}>{pr.prTitle}</span>
932 <span class="dash-rq-age">
933 {formatRelative(pr.createdAt)}
934 </span>
935 </a>
936 ))}
937 </div>
938 )}
939 {myOpenPrs.length > 0 && (
940 <div class="dash-rq">
941 <div class="dash-rq-head">
942 <span>{"○"} Your open PRs</span>
943 <span class="dash-rq-head-count">{myOpenPrs.length}</span>
944 </div>
945 {myOpenPrs.map((pr) => (
946 <a
947 href={`/${pr.ownerUsername}/${pr.repoName}/pulls/${pr.prNumber}`}
948 class="dash-rq-row"
949 >
950 <span class="dash-rq-repo">{pr.repoName}</span>
951 <span class="dash-rq-title" title={pr.prTitle}>{pr.prTitle}</span>
952 <span class="dash-rq-age">
953 {formatRelative(pr.createdAt)}
954 </span>
955 </a>
956 ))}
957 </div>
958 )}
959 </div>
960 </>
961 )}
962
f1ab587Claude963 {/* ─── Quick Links ─── */}
dc26881CC LABS App964 <div style="margin-top: var(--space-8); display: flex; gap: var(--space-4); flex-wrap: wrap">
f1ab587Claude965 <a href="/explore" class="btn">Browse public repos</a>
966 <a href="/settings/tokens" class="btn">API tokens</a>
967 <a href="/settings/keys" class="btn">SSH keys</a>
968 </div>
969 </Layout>
970 );
971});
972
973// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
974
975dashboard.get(
976 "/:owner/:repo/settings/intelligence",
977 requireAuth,
978 async (c) => {
979 const { owner: ownerName, repo: repoName } = c.req.param();
980 const user = c.get("user")!;
981 const success = c.req.query("success");
982
983 return c.html(
984 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
985 <div style="max-width: 600px">
986 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
987 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
988 Control what gluecron does automatically when code is pushed to{" "}
989 <strong>{ownerName}/{repoName}</strong>.
990 </p>
991 {success && (
992 <div class="auth-success">{decodeURIComponent(success)}</div>
993 )}
994 <form
0316dbbClaude995 method="post"
f1ab587Claude996 action={`/${ownerName}/${repoName}/settings/intelligence`}
997 >
998 <ToggleSetting
999 name="auto_repair"
1000 label="Auto-Repair"
1001 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
1002 defaultChecked={true}
1003 />
1004 <ToggleSetting
1005 name="security_scan"
1006 label="Security Scanning"
1007 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
1008 defaultChecked={true}
1009 />
1010 <ToggleSetting
1011 name="health_score"
1012 label="Health Score"
1013 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
1014 defaultChecked={true}
1015 />
1016 <ToggleSetting
1017 name="push_analysis"
1018 label="Push Risk Analysis"
1019 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
1020 defaultChecked={true}
1021 />
1022 <ToggleSetting
1023 name="dep_analysis"
1024 label="Dependency Analysis"
1025 description="Build import graph, detect unused deps, find circular dependencies"
1026 defaultChecked={true}
1027 />
1028 <ToggleSetting
1029 name="gatetest"
1030 label="GateTest Integration"
1031 description="Send push events to GateTest for external security scanning"
1032 defaultChecked={true}
1033 />
1034 <ToggleSetting
90fa787Claude1035 name="deploy_webhook"
1036 label="Auto-Deploy Webhook"
1037 description="POST to your configured deploy webhook when pushing to the default branch"
f1ab587Claude1038 defaultChecked={true}
1039 />
1040
1041 <button
1042 type="submit"
1043 class="btn btn-primary"
1044 style="margin-top: 12px"
1045 >
1046 Save settings
1047 </button>
1048 </form>
1049 </div>
1050 </Layout>
1051 );
1052 }
1053);
1054
1055dashboard.post(
1056 "/:owner/:repo/settings/intelligence",
1057 requireAuth,
1058 async (c) => {
1059 const { owner: ownerName, repo: repoName } = c.req.param();
1060 // In production, these would be saved to DB per-repo
1061 // For now, acknowledge the settings
1062 return c.redirect(
1063 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
1064 );
1065 }
1066);
1067
1068// ─── PUSH LOG ────────────────────────────────────────────────
1069
1070dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
1071 const { owner, repo } = c.req.param();
1072 const user = c.get("user");
1073
1074 if (!(await repoExists(owner, repo))) return c.notFound();
1075 const ref = (await getDefaultBranch(owner, repo)) || "main";
1076 const commits = await listCommits(owner, repo, ref, 30);
1077
1078 return c.html(
1079 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
1080 <div style="max-width: 900px">
1081 <h2 style="margin-bottom: 4px">Push Log</h2>
1082 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
1083 Every push analyzed in real-time — risk scores, repairs, security alerts
1084 </p>
1085 <div class="issue-list">
1086 {commits.map((commit) => {
1087 // Determine if this was an auto-repair commit
1088 const isRepair =
1089 commit.author === "gluecron[bot]" ||
1090 commit.message.includes("auto-repair");
1091 const isRollback = commit.message.startsWith("revert: rollback");
1092
1093 return (
1094 <div class="issue-item" style="flex-direction: column; align-items: stretch">
1095 <div style="display: flex; justify-content: space-between; align-items: start">
dc26881CC LABS App1096 <div style="display: flex; gap: var(--space-2); align-items: start">
f1ab587Claude1097 {isRepair ? (
1098 <span
1099 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
1100 title="Auto-repair"
1101 >
1102 {"⚡"}
1103 </span>
1104 ) : isRollback ? (
1105 <span
1106 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
1107 title="Rollback"
1108 >
1109 {"↩"}
1110 </span>
1111 ) : (
1112 <span
1113 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
1114 >
1115 {"→"}
1116 </span>
1117 )}
1118 <div>
1119 <a
1120 href={`/${owner}/${repo}/commit/${commit.sha}`}
1121 style="font-weight: 600; font-size: 14px"
1122 >
1123 {commit.message}
1124 </a>
1125 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
1126 {commit.author} —{" "}
1127 {new Date(commit.date).toLocaleString("en-US", {
1128 month: "short",
1129 day: "numeric",
1130 hour: "2-digit",
1131 minute: "2-digit",
1132 })}
1133 </div>
1134 </div>
1135 </div>
1136 <a
1137 href={`/${owner}/${repo}/commit/${commit.sha}`}
1138 class="commit-sha"
1139 >
1140 {commit.sha.slice(0, 7)}
1141 </a>
1142 </div>
1143 {isRepair && (
1144 <div
dc26881CC LABS App1145 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)"
f1ab587Claude1146 >
1147 Automatically repaired by gluecron
1148 </div>
1149 )}
1150 </div>
1151 );
1152 })}
1153 </div>
1154 </div>
1155 </Layout>
1156 );
1157});
1158
1159// ─── COMPONENTS ──────────────────────────────────────────────
1160
2d78948Claude1161// ─── AI Activity — Last Hour widget ─────────────────────────────────────────
1162
1163const AI_ACTIVITY_ICON: Record<AiActivityItem["kind"], string> = {
1164 pr_auto_merged: "⮌", // ⬌ (merged)
1165 spec_shipped: "\u{1F4E6}", // 📦
1166 ci_healed: "⚡", // ⚡
1167 secret_repaired: "\u{1F512}", // 🔒
1168};
1169
1170const AI_ACTIVITY_LABEL: Record<AiActivityItem["kind"], string> = {
1171 pr_auto_merged: "PR auto-merged",
1172 spec_shipped: "Spec shipped",
1173 ci_healed: "CI healed",
1174 secret_repaired: "Secret repaired",
1175};
1176
1177const AI_ACTIVITY_COLOR: Record<AiActivityItem["kind"], string> = {
1178 pr_auto_merged: "var(--accent)",
1179 spec_shipped: "var(--green)",
1180 ci_healed: "#58a6ff",
1181 secret_repaired: "var(--yellow)",
1182};
1183
1184const AiActivityWidget = ({
1185 activity,
1186 username,
1187}: {
1188 activity: AiActivityReport;
1189 username: string;
1190}) => {
1191 const total =
1192 activity.prsAutoMerged +
1193 activity.specsShipped +
1194 activity.ciHealed +
1195 activity.secretsRepaired;
1196
1197 const summaryParts: string[] = [];
1198 if (activity.prsAutoMerged > 0)
1199 summaryParts.push(
1200 `${activity.prsAutoMerged} PR${activity.prsAutoMerged === 1 ? "" : "s"} auto-merged`
1201 );
1202 if (activity.specsShipped > 0)
1203 summaryParts.push(
1204 `${activity.specsShipped} spec${activity.specsShipped === 1 ? "" : "s"} shipped`
1205 );
1206 if (activity.ciHealed > 0)
1207 summaryParts.push(
1208 `${activity.ciHealed} CI run${activity.ciHealed === 1 ? "" : "s"} healed`
1209 );
1210 if (activity.secretsRepaired > 0)
1211 summaryParts.push(
1212 `${activity.secretsRepaired} secret${activity.secretsRepaired === 1 ? "" : "s"} repaired`
1213 );
1214
1215 // Show at most 6 items in the expanded list to keep the card compact.
1216 const shownItems = activity.items.slice(0, 6);
1217
1218 return (
1219 <div
1220 class="card"
1221 style="margin-bottom: var(--space-6); padding: 0; overflow: hidden"
1222 >
1223 {/* Card header */}
1224 <div
1225 style="display:flex;align-items:center;justify-content:space-between;padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border);background:var(--bg-elevated)"
1226 >
1227 <div style="display:flex;align-items:center;gap:var(--space-2)">
1228 <span style="font-size:15px" aria-hidden="true">{"\u{1F916}"}</span>
1229 <div>
1230 <span style="font-size:14px;font-weight:600;color:var(--text-strong)">
1231 AI Activity
1232 </span>
1233 <span
1234 style="margin-left:6px;font-size:11px;color:var(--text-muted);font-weight:400"
1235 >
1236 last hour
1237 </span>
1238 </div>
1239 </div>
1240 {total > 0 && (
1241 <span
1242 style="font-size:11px;font-weight:700;padding:2px 8px;border-radius:9999px;background:rgba(140,109,255,0.14);color:var(--accent);border:1px solid rgba(140,109,255,0.25)"
1243 >
1244 {total} action{total === 1 ? "" : "s"}
1245 </span>
1246 )}
1247 </div>
1248
1249 {total === 0 ? (
1250 /* Empty state */
1251 <div
1252 style="padding:var(--space-5) var(--space-4);text-align:center;color:var(--text-muted);font-size:13px"
1253 >
1254 <div style="font-size:22px;margin-bottom:6px" aria-hidden="true">
1255 {"\u{1F441}"}
1256 </div>
1257 All quiet — AI is watching.
1258 </div>
1259 ) : (
1260 <>
1261 {/* Summary pills */}
1262 <div
1263 style="display:flex;flex-wrap:wrap;gap:6px;padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)"
1264 >
1265 {summaryParts.map((p) => (
1266 <span
1267 class="badge"
1268 style="font-size:12px;padding:3px 9px;background:rgba(140,109,255,0.08);border-color:rgba(140,109,255,0.22);color:var(--text)"
1269 >
1270 {p}
1271 </span>
1272 ))}
1273 </div>
1274
1275 {/* Item list */}
1276 <ul style="list-style:none;margin:0;padding:0">
1277 {shownItems.map((item) => {
1278 // Build a link to the relevant resource if we have a number
1279 let href: string | undefined;
1280 if (item.refNumber !== null) {
1281 const base = `/${item.repoOwner}/${item.repoName}`;
1282 if (item.kind === "pr_auto_merged") {
1283 href = `${base}/pulls/${item.refNumber}`;
1284 } else if (item.kind === "spec_shipped") {
1285 href = `${base}/issues/${item.refNumber}`;
1286 }
1287 }
1288 const color = AI_ACTIVITY_COLOR[item.kind];
1289 const icon = AI_ACTIVITY_ICON[item.kind];
1290 const kindLabel = AI_ACTIVITY_LABEL[item.kind];
1291 return (
1292 <li
1293 style="display:flex;align-items:center;gap:10px;padding:8px var(--space-4);border-bottom:1px solid var(--border);font-size:13px"
1294 >
1295 <span
1296 style={`font-size:15px;width:20px;text-align:center;flex-shrink:0;color:${color}`}
1297 aria-label={kindLabel}
1298 >
1299 {icon}
1300 </span>
1301 <span
1302 style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
1303 title={item.label}
1304 >
1305 <span style="color:var(--text-muted);font-size:11px;margin-right:4px">
1306 {kindLabel}
1307 </span>
1308 {href ? (
1309 <a
1310 href={href}
1311 style="font-weight:500;color:var(--text)"
1312 >
1313 {item.label}
1314 </a>
1315 ) : (
1316 <span style="font-weight:500">{item.label}</span>
1317 )}
1318 </span>
1319 <span
1320 style="font-size:11px;color:var(--text-muted);flex-shrink:0;white-space:nowrap"
1321 >
1322 <a
1323 href={`/${item.repoOwner}/${item.repoName}`}
1324 style="color:var(--text-muted)"
1325 >
1326 {item.repoName}
1327 </a>
1328 <span style="margin-left:4px">
1329 {formatRelative(item.occurredAt)}
1330 </span>
1331 </span>
1332 </li>
1333 );
1334 })}
1335 </ul>
1336
1337 {activity.items.length > 6 && (
1338 <div
1339 style="padding:8px var(--space-4);font-size:12px;color:var(--text-muted);border-top:1px solid var(--border)"
1340 >
1341 + {activity.items.length - 6} more action{activity.items.length - 6 === 1 ? "" : "s"} in the last hour
1342 </div>
1343 )}
1344 </>
1345 )}
1346 </div>
1347 );
1348};
1349
46d6165Claude1350/**
1351 * Block L9 — pure formatter used by the dashboard widget AND tests.
1352 * Turns the breakdown into the small stat-pill array shown under the
1353 * big number. Exported so the markup contract is testable without
1354 * importing JSX.
1355 */
1356export function formatSavingsPills(b: {
1357 prsAutoMerged: number;
1358 issuesBuiltByAi: number;
1359 aiReviewsPosted: number;
1360 aiTriagesPosted: number;
1361 aiCommitMsgs: number;
1362 secretsAutoRepaired: number;
1363 gateAutoRepairs: number;
1364}): string[] {
1365 const pills: string[] = [];
1366 if (b.prsAutoMerged) pills.push(`${b.prsAutoMerged} PR${b.prsAutoMerged === 1 ? "" : "s"} auto-merged`);
1367 if (b.issuesBuiltByAi) pills.push(`${b.issuesBuiltByAi} issue${b.issuesBuiltByAi === 1 ? "" : "s"} built`);
1368 if (b.aiReviewsPosted) pills.push(`${b.aiReviewsPosted} AI review${b.aiReviewsPosted === 1 ? "" : "s"}`);
1369 if (b.aiTriagesPosted) pills.push(`${b.aiTriagesPosted} triage${b.aiTriagesPosted === 1 ? "" : "s"}`);
1370 const fixes = b.secretsAutoRepaired + b.gateAutoRepairs;
1371 if (fixes) pills.push(`${fixes} auto-fix${fixes === 1 ? "" : "es"}`);
1372 if (b.aiCommitMsgs) pills.push(`${b.aiCommitMsgs} commit msg${b.aiCommitMsgs === 1 ? "" : "s"}`);
1373 return pills;
1374}
1375
1376const AiHoursSavedWidget = ({
1377 week,
1378 lifetime,
1379}: {
1380 week: AiSavingsReport;
1381 lifetime: AiSavingsLifetimeReport;
1382}) => {
1383 const weekPills = formatSavingsPills(week.breakdown);
1384 const lifetimePills = formatSavingsPills(lifetime.breakdown);
1385 const hasAnyWeek = week.hoursSaved > 0 || weekPills.length > 0;
1386 return (
1387 <div
1388 class="card ai-hours-saved-widget"
1389 style="margin-bottom: 24px; padding: 0; overflow: hidden; position: relative; background: var(--accent-gradient-faint, var(--bg-secondary)); border-color: var(--accent)"
1390 >
dc26881CC LABS App1391 <div style="padding: var(--space-6) var(--space-6) var(--space-5) var(--space-6)">
1392 <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-4);flex-wrap:wrap">
46d6165Claude1393 <div style="flex:1;min-width:240px">
1394 <div style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px">
1395 AI working for you
1396 </div>
1397 <div
1398 data-testid="ai-hours-saved-this-week"
1399 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"
1400 >
1401 {week.hoursSaved.toFixed(1)}h
1402 </div>
1403 <div style="margin-top: 6px; font-size: 14px; color: var(--text-muted)">
1404 Claude saved you{" "}
1405 <strong style="color: var(--text)">
1406 {week.hoursSaved.toFixed(1)} hours
1407 </strong>{" "}
1408 this week.
1409 {lifetime.hoursSaved > week.hoursSaved && (
1410 <span>
1411 {" — "}
1412 <strong style="color: var(--text)">
1413 {lifetime.hoursSaved.toFixed(1)}h
1414 </strong>{" "}
1415 all-time.
1416 </span>
1417 )}
1418 </div>
1419 </div>
1420 <div
1421 class="ai-hours-saved-tabs"
1422 style="display:flex;gap:4px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;padding:2px"
1423 >
1424 <span
1425 data-tab="this-week"
1426 style="padding:4px 10px;font-size:12px;font-weight:600;border-radius:4px;background:var(--bg);color:var(--text)"
1427 >
1428 This week
1429 </span>
1430 <span
1431 data-tab="all-time"
1432 style="padding:4px 10px;font-size:12px;color:var(--text-muted)"
1433 >
1434 All-time
1435 </span>
1436 </div>
1437 </div>
1438
1439 {hasAnyWeek ? (
dc26881CC LABS App1440 <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-4)">
46d6165Claude1441 {weekPills.map((p) => (
1442 <span
1443 class="badge"
1444 style="font-size:12px;padding:4px 10px;background:rgba(140,109,255,0.10);border-color:var(--accent);color:var(--text)"
1445 >
1446 {p}
1447 </span>
1448 ))}
1449 </div>
1450 ) : (
1451 <div style="margin-top:16px;font-size:13px;color:var(--text-muted)">
1452 No AI activity this week yet — open a PR, label an issue{" "}
1453 <code>ai:build</code>, or let auto-merge sweep your branches.
1454 The counter will start climbing.
1455 </div>
1456 )}
1457
1458 <details style="margin-top:16px">
1459 <summary
1460 data-testid="ai-hours-saved-formula-toggle"
1461 style="cursor:pointer;font-size:12px;color:var(--text-muted);user-select:none"
1462 >
1463 How is this calculated?
1464 </summary>
1465 <div
1466 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"
1467 >
1468 <div>hoursSaved =</div>
1469 <div>&nbsp;&nbsp;{week.breakdown.prsAutoMerged} PRs auto-merged × 0.30</div>
1470 <div>+ {week.breakdown.issuesBuiltByAi} issues built by AI × 1.50</div>
1471 <div>+ {week.breakdown.aiReviewsPosted} AI reviews × 0.25</div>
1472 <div>+ {week.breakdown.aiTriagesPosted} AI triages × 0.10</div>
1473 <div>+ {week.breakdown.aiCommitMsgs} AI commit msgs × 0.05</div>
1474 <div>+ {week.breakdown.secretsAutoRepaired} secrets repaired × 0.50</div>
1475 <div>+ {week.breakdown.gateAutoRepairs} gates repaired × 0.40</div>
1476 <div style="margin-top:6px;color:var(--text)">
1477 = {week.hoursSaved.toFixed(1)}h (this week,{" "}
1478 {week.windowHours}h window)
1479 </div>
1480 <div style="margin-top:8px;font-size:11px">
1481 Lifetime: {lifetime.hoursSaved.toFixed(1)}h since{" "}
1482 {lifetime.sinceCreatedAt.toISOString().slice(0, 10)}.
1483 Constants are conservative on purpose — audit-friendly is
1484 the brand.
1485 </div>
1486 {lifetimePills.length > 0 && (
1487 <div style="margin-top:8px;font-size:11px">
1488 All-time breakdown: {lifetimePills.join(" · ")}
1489 </div>
1490 )}
1491 </div>
1492 </details>
1493 </div>
1494 </div>
1495 );
1496};
1497
7a14837Claude1498/**
1499 * Pure helper: pick the bottom-N repos by health score and return a
1500 * prioritized "fix this next" plan. Health=0 repos (couldn't be
1501 * computed) are excluded so the coach doesn't recommend ghost repos.
1502 *
1503 * Exported under __test for unit testing without touching the DB.
1504 */
1505export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
1506 repoData: T[],
1507 topN = 3
1508): T[] {
1509 return repoData
1510 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
1511 .sort((a, b) => a.healthScore - b.healthScore)
1512 .slice(0, topN);
1513}
1514
1515/** Module-scoped color picker for grade chips. Mirrors the inner
1516 * `gradeColor` defined in the request handler scope, exposed at module
1517 * level so HealthCoach (also module-scope) can reach it. */
1518function moduleGradeColor(grade: string): string {
1519 if (grade === "A+" || grade === "A") return "var(--green)";
1520 if (grade === "B") return "#58a6ff";
1521 if (grade === "C") return "var(--yellow)";
1522 if (grade === "?") return "var(--text-muted)";
1523 return "var(--red)";
1524}
1525
1526const HealthCoach = ({
1527 repoData,
1528 username,
1529}: {
1530 repoData: Array<{
1531 repo: { name: string; description: string | null };
1532 healthScore: number;
1533 healthGrade: string;
1534 }>;
1535 username: string;
1536}) => {
1537 const picks = pickRepoCoachPicks(repoData, 3);
1538 if (picks.length === 0) {
1539 return (
1540 <div
1541 class="card"
dc26881CC LABS App1542 style="margin-bottom: var(--space-8); padding: var(--space-4); background: rgba(63,185,80,0.08); border-color: var(--green)"
7a14837Claude1543 >
1544 <h3 style="margin: 0 0 4px; font-size: 15px">
1545 {"✨"} AI Health Coach
1546 </h3>
1547 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
1548 All your repos are healthy (score &ge; 90). Nothing to triage.
1549 </p>
1550 </div>
1551 );
1552 }
1553 return (
1554 <div
1555 class="card"
1556 style="margin-bottom: 32px; padding: 0; overflow: hidden"
1557 >
1558 <div
dc26881CC LABS App1559 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
7a14837Claude1560 >
1561 <div>
1562 <h3 style="margin: 0; font-size: 15px">
1563 {"✨"} AI Health Coach
1564 </h3>
1565 <p
dc26881CC LABS App1566 style="margin: var(--space-1) 0 0; color: var(--text-muted); font-size: 12px"
7a14837Claude1567 >
1568 Top {picks.length} repos that would benefit from attention
1569 this week.
1570 </p>
1571 </div>
1572 </div>
1573 <ul style="list-style: none; margin: 0; padding: 0">
1574 {picks.map((p) => (
1575 <li
dc26881CC LABS App1576 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: var(--space-3)"
7a14837Claude1577 >
1578 <div
1579 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
1580 >
1581 {p.healthGrade}
1582 </div>
1583 <div style="flex: 1; min-width: 0">
1584 <a
1585 href={`/${username}/${p.repo.name}`}
1586 style="font-weight: 500"
1587 >
1588 {p.repo.name}
1589 </a>
1590 <div
1591 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
1592 >
1593 Health score {p.healthScore}/100 — open the repo to see
1594 breakdown + AI suggestions.
1595 </div>
1596 </div>
1597 <a
f077ea5Claude1598 href={`/${username}/${p.repo.name}/health`}
7a14837Claude1599 class="btn"
1600 style="font-size: 12px; padding: 4px 10px"
f077ea5Claude1601 title="Open health score with AI suggestions"
7a14837Claude1602 >
1603 Coach me
1604 </a>
1605 </li>
1606 ))}
1607 </ul>
1608 </div>
1609 );
1610};
1611
f1ab587Claude1612const StatBox = ({
1613 label,
1614 value,
1615 color,
1616}: {
1617 label: string;
1618 value: string;
1619 color: string;
1620}) => (
1621 <div
dc26881CC LABS App1622 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); text-align: center"
f1ab587Claude1623 >
1624 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
1625 {value}
1626 </div>
1627 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
1628 {label}
1629 </div>
1630 </div>
1631);
1632
1633const ToggleSetting = ({
1634 name,
1635 label,
1636 description,
1637 defaultChecked,
1638}: {
1639 name: string;
1640 label: string;
1641 description: string;
1642 defaultChecked: boolean;
1643}) => (
1644 <div
dc26881CC LABS App1645 style="display: flex; justify-content: space-between; align-items: start; padding: var(--space-4) 0; border-bottom: 1px solid var(--border)"
f1ab587Claude1646 >
1647 <div style="flex: 1">
1648 <div style="font-size: 15px; font-weight: 600">{label}</div>
1649 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
1650 {description}
1651 </div>
1652 </div>
1653 <label class="toggle-switch">
2228c49copilot-swe-agent[bot]1654 <input type="checkbox" name={name} value="on" checked={defaultChecked} aria-label={label} />
f1ab587Claude1655 <span class="toggle-slider" />
1656 </label>
1657 </div>
1658);
1659
1660const ActivityIcon = ({ action }: { action: string }) => {
1661 const icons: Record<string, string> = {
1662 push: "→",
1663 issue_open: "\u25CB",
1664 issue_close: "\u2713",
1665 pr_open: "\u25CB",
1666 pr_merge: "\u2B8C",
1667 star: "\u2605",
1668 fork: "\u2442",
1669 comment: "\u{1F4AC}",
1670 };
1671 return (
1672 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
1673 {icons[action] || "•"}
1674 </span>
1675 );
1676};
1677
1678function formatAction(action: string): string {
1679 const labels: Record<string, string> = {
1680 push: "Pushed code",
1681 issue_open: "Opened issue",
1682 issue_close: "Closed issue",
1683 pr_open: "Opened pull request",
1684 pr_merge: "Merged pull request",
1685 star: "Starred",
1686 fork: "Forked",
1687 comment: "Commented",
1688 };
1689 return labels[action] || action;
1690}
1691
1692function formatRelative(date: Date | string): string {
1693 const d = typeof date === "string" ? new Date(date) : date;
1694 const now = new Date();
1695 const diffMs = now.getTime() - d.getTime();
1696 const diffMins = Math.floor(diffMs / 60000);
1697 if (diffMins < 1) return "just now";
1698 if (diffMins < 60) return `${diffMins}m ago`;
1699 const diffHours = Math.floor(diffMins / 60);
1700 if (diffHours < 24) return `${diffHours}h ago`;
1701 const diffDays = Math.floor(diffHours / 24);
1702 if (diffDays < 30) return `${diffDays}d ago`;
1703 return d.toLocaleDateString("en-US", {
1704 month: "short",
1705 day: "numeric",
1706 });
1707}
1708
f1dc7c7Claude1709// ─── Loading skeleton (flag-gated; renders when ?skeleton=1) ──────────
1710const DashboardSkeleton = () => (
1711 <>
1712 <style
1713 dangerouslySetInnerHTML={{
1714 __html: `
1715 .dash-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: dashSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
1716 @keyframes dashSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1717 @media (prefers-reduced-motion: reduce) { .dash-skel { animation: none; } }
1718 .dash-skel-hero { height: 168px; border-radius: 16px; margin-bottom: var(--space-6); }
1719 .dash-skel-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8); }
1720 .dash-skel-stat { height: 86px; border-radius: var(--radius); }
1721 .dash-skel-h { height: 18px; width: 180px; margin: 0 0 16px; border-radius: 5px; }
1722 .dash-skel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8); }
1723 .dash-skel-card { height: 168px; border-radius: var(--radius); }
1724 .dash-skel-feed { display: flex; flex-direction: column; gap: 8px; }
1725 .dash-skel-feed-row { height: 52px; border-radius: var(--radius); }
1726 `,
1727 }}
1728 />
1729 <div class="dash-skel dash-skel-hero" aria-hidden="true" />
1730 <div class="dash-skel-stats" aria-hidden="true">
1731 <div class="dash-skel dash-skel-stat" />
1732 <div class="dash-skel dash-skel-stat" />
1733 <div class="dash-skel dash-skel-stat" />
1734 <div class="dash-skel dash-skel-stat" />
1735 </div>
1736 <div class="dash-skel dash-skel-h" aria-hidden="true" />
1737 <div class="dash-skel-grid" aria-hidden="true">
1738 <div class="dash-skel dash-skel-card" />
1739 <div class="dash-skel dash-skel-card" />
1740 <div class="dash-skel dash-skel-card" />
1741 <div class="dash-skel dash-skel-card" />
1742 </div>
1743 <div class="dash-skel dash-skel-h" aria-hidden="true" />
1744 <div class="dash-skel-feed" aria-hidden="true">
1745 <div class="dash-skel dash-skel-feed-row" />
1746 <div class="dash-skel dash-skel-feed-row" />
1747 <div class="dash-skel dash-skel-feed-row" />
1748 <div class="dash-skel dash-skel-feed-row" />
1749 <div class="dash-skel dash-skel-feed-row" />
1750 </div>
1751 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
1752 Loading your command center…
1753 </span>
1754 </>
1755);
1756
c6018a5Claude1757// ─── Quick actions — 5-card panel of the most-leverage AI features.
1758// Surfaces the post-K1 AI surfaces (Voice, Standups, Refactors, repo
1759// chat, Pulls dashboard) so signed-in users can discover them without
1760// hunting through the top-nav dropdown. CSS scoped under `.qa-` to
1761// avoid bleed; no shared component touched.
1762const QuickActionsPanel = ({
1763 firstRepo,
1764 username,
1765}: {
1766 firstRepo: { name: string } | undefined;
1767 username: string;
1768}) => {
1769 const chatHref = firstRepo
1770 ? `/${username}/${firstRepo.name}/chat`
1771 : "/explore";
1772 const chatSub = firstRepo
1773 ? `Open ${firstRepo.name} rubber-duck chat`
1774 : "Pick a repo, then ask anything";
1775 return (
1776 <div class="qa-panel" aria-label="Quick AI actions">
1777 <style
1778 dangerouslySetInnerHTML={{
1779 __html: `
1780 .qa-panel {
1781 position: relative;
1782 margin-bottom: var(--space-6);
1783 padding: var(--space-4) var(--space-5) var(--space-5);
1784 background: var(--bg-elevated);
1785 border: 1px solid var(--border);
1786 border-radius: 14px;
1787 overflow: hidden;
1788 }
1789 .qa-panel::before {
1790 content: '';
1791 position: absolute;
1792 top: 0; left: 0; right: 0;
1793 height: 1px;
1794 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1795 opacity: 0.65;
1796 pointer-events: none;
1797 }
1798 .qa-eyebrow {
1799 font-size: 11px;
1800 font-weight: 600;
1801 letter-spacing: 0.08em;
1802 text-transform: uppercase;
1803 color: var(--accent);
1804 margin-bottom: 4px;
1805 }
1806 .qa-title {
1807 font-family: var(--font-display);
1808 font-size: 17px;
1809 font-weight: 700;
1810 letter-spacing: -0.018em;
1811 margin: 0 0 var(--space-3);
1812 color: var(--text-strong);
1813 }
1814 .qa-grid {
1815 display: grid;
1816 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1817 gap: var(--space-2);
1818 }
1819 .qa-card {
1820 display: flex;
1821 flex-direction: column;
1822 gap: 4px;
1823 padding: var(--space-3) var(--space-3);
1824 background: var(--bg);
1825 border: 1px solid var(--border);
1826 border-radius: 10px;
1827 color: var(--text);
1828 text-decoration: none;
1829 transition: border-color 160ms ease, transform 160ms ease, background 160ms ease;
1830 position: relative;
1831 overflow: hidden;
1832 }
1833 .qa-card::before {
1834 content: '';
1835 position: absolute;
1836 top: 0; left: 0; right: 0;
1837 height: 1px;
1838 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.55) 30%, rgba(54,197,214,0.55) 70%, transparent 100%);
1839 opacity: 0;
1840 transition: opacity 160ms ease;
1841 }
1842 .qa-card:hover {
1843 border-color: rgba(140,109,255,0.40);
1844 text-decoration: none;
1845 transform: translateY(-1px);
1846 background: linear-gradient(180deg, rgba(140,109,255,0.04), transparent);
1847 }
1848 .qa-card:hover::before { opacity: 1; }
1849 .qa-card-label {
1850 font-size: 13.5px;
1851 font-weight: 600;
1852 color: var(--text-strong);
1853 display: flex;
1854 align-items: center;
1855 gap: 6px;
1856 }
1857 .qa-card-sub {
1858 font-size: 12px;
1859 color: var(--text-muted);
1860 line-height: 1.4;
1861 }
1862 .qa-card-icon {
1863 display: inline-flex;
1864 width: 18px; height: 18px;
1865 align-items: center;
1866 justify-content: center;
1867 border-radius: 5px;
1868 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.14));
1869 color: #b69dff;
1870 font-size: 11px;
1871 font-weight: 700;
1872 flex-shrink: 0;
1873 }
1874 `,
1875 }}
1876 />
1877 <div class="qa-eyebrow">Quick actions</div>
1878 <h3 class="qa-title">Ship faster with Claude</h3>
1879 <div class="qa-grid">
1880 <a href="/voice" class="qa-card">
1881 <span class="qa-card-label">
1882 <span class="qa-card-icon" aria-hidden="true">{"\u{1F3A4}"}</span>
1883 Talk to ship
1884 </span>
1885 <span class="qa-card-sub">Voice → PR in one take</span>
1886 </a>
1887 <a href="/standups" class="qa-card">
1888 <span class="qa-card-label">
1889 <span class="qa-card-icon" aria-hidden="true">{"\u{1F4DD}"}</span>
1890 Today's standup
1891 </span>
1892 <span class="qa-card-sub">Daily AI brief, fresh on demand</span>
1893 </a>
1894 <a href="/refactors" class="qa-card">
1895 <span class="qa-card-label">
1896 <span class="qa-card-icon" aria-hidden="true">{"⚡"}</span>
1897 Refactor everywhere
1898 </span>
1899 <span class="qa-card-sub">One brief → PRs across all repos</span>
1900 </a>
1901 <a href={chatHref} class="qa-card">
1902 <span class="qa-card-label">
1903 <span class="qa-card-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
1904 Chat with a repo
1905 </span>
1906 <span class="qa-card-sub">{chatSub}</span>
1907 </a>
1908 <a href="/pulls" class="qa-card">
1909 <span class="qa-card-label">
1910 <span class="qa-card-icon" aria-hidden="true">{"\u{1F500}"}</span>
1911 Watch the PR queue
1912 </span>
1913 <span class="qa-card-sub">Global pulls across your repos</span>
1914 </a>
1915 </div>
1916 </div>
1917 );
1918};
1919
f1ab587Claude1920export default dashboard;