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.tsxBlame770 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";
41
42const dashboard = new Hono<AuthEnv>();
43
44dashboard.use("*", softAuth);
45
46// ─── COMMAND CENTER ──────────────────────────────────────────
47
48dashboard.get("/dashboard", requireAuth, async (c) => {
49 const user = c.get("user")!;
50
51 // Get all user's repos
52 const repos = await db
53 .select()
54 .from(repositories)
55 .where(eq(repositories.ownerId, user.id))
56 .orderBy(desc(repositories.updatedAt));
57
58 // Compute health scores for all repos (in parallel)
59 const repoData = await Promise.all(
60 repos.map(async (repo) => {
61 let healthScore = 0;
62 let healthGrade = "?" as string;
63 let recentCommits = 0;
64 let branchCount = 0;
65 let ciConfig = null;
66
67 try {
68 if (await repoExists(user.username, repo.name)) {
69 const ref =
70 (await getDefaultBranch(user.username, repo.name)) || "main";
71 const [health, commits, branches, ci] = await Promise.all([
72 computeHealthScore(user.username, repo.name).catch(() => null),
73 listCommits(user.username, repo.name, ref, 5).catch(() => []),
74 listBranches(user.username, repo.name).catch(() => []),
75 detectCIConfig(user.username, repo.name, ref).catch(() => null),
76 ]);
77 if (health) {
78 healthScore = health.score;
79 healthGrade = health.grade;
80 }
81 recentCommits = commits.length;
82 branchCount = branches.length;
83 ciConfig = ci;
84 }
85 } catch {
86 // best effort
87 }
88
89 return {
90 repo,
91 healthScore,
92 healthGrade,
93 recentCommits,
94 branchCount,
95 ciConfig,
96 };
97 })
98 );
99
100 // Get recent activity
101 let recentActivity: Array<{
102 action: string;
103 repoName: string;
104 metadata: string | null;
105 createdAt: Date;
106 }> = [];
107
108 try {
109 const repoIds = repos.map((r) => r.id);
110 if (repoIds.length > 0) {
111 const activity = await db
112 .select({
113 action: activityFeed.action,
114 metadata: activityFeed.metadata,
115 createdAt: activityFeed.createdAt,
116 repoId: activityFeed.repositoryId,
117 })
118 .from(activityFeed)
119 .where(eq(activityFeed.userId, user.id))
120 .orderBy(desc(activityFeed.createdAt))
121 .limit(20);
122
123 recentActivity = activity.map((a) => ({
124 action: a.action,
125 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
126 metadata: a.metadata,
127 createdAt: a.createdAt,
128 }));
129 }
130 } catch {
131 // DB not required for dashboard
132 }
133
134 const gradeColor = (grade: string) =>
135 grade === "A+" || grade === "A"
136 ? "var(--green)"
137 : grade === "B"
138 ? "#58a6ff"
139 : grade === "C"
140 ? "var(--yellow)"
141 : grade === "?"
142 ? "var(--text-muted)"
143 : "var(--red)";
144
145 return c.html(
146 <Layout title="Command Center" user={user}>
147 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px">
148 <div>
149 <h1 style="font-size: 28px; margin-bottom: 4px">Command Center</h1>
150 <p style="color: var(--text-muted); font-size: 14px">
151 Real-time overview of all your repositories
152 </p>
153 </div>
154 <div style="display: flex; gap: 8px">
155 <a href="/new" class="btn btn-primary">+ New repo</a>
156 <a href="/settings" class="btn">Settings</a>
157 </div>
158 </div>
159
160 {/* ─── Stats Bar ─── */}
161 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 32px">
162 <StatBox
163 label="Repositories"
164 value={String(repos.length)}
165 color="var(--text-link)"
166 />
167 <StatBox
168 label="Avg Health"
169 value={
170 repos.length > 0
171 ? String(
172 Math.round(
173 repoData.reduce((s, r) => s + r.healthScore, 0) /
174 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
175 )
176 )
177 : "—"
178 }
179 color="var(--green)"
180 />
181 <StatBox
182 label="Total Stars"
183 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
184 color="var(--yellow)"
185 />
186 <StatBox
187 label="Open Issues"
188 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
189 color="var(--red)"
190 />
191 </div>
192
193 {/* ─── Repo Grid ─── */}
194 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
195 {repos.length === 0 ? (
80bed05Claude196 <div class="empty-state" style="text-align:left;padding:24px">
197 <div style="text-align:center;margin-bottom:20px">
198 <h2 style="margin-bottom:6px">Get started</h2>
199 <p style="color:var(--text-muted);font-size:14px;margin:0">
200 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
201 </p>
202 </div>
203 <div class="panel" style="margin-bottom:20px;text-align:left">
204 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
205 <div style="flex:1">
206 <div style="font-size:15px;font-weight:600">Create a new repository</div>
207 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
208 Start from scratch with green-ecosystem defaults.
209 </div>
210 </div>
211 <a href="/new" class="btn btn-primary">Create repo</a>
212 </div>
213 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
214 <div style="flex:1">
215 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
216 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
217 Mirror an existing repo — history, branches, tags.
218 </div>
219 </div>
220 <a href="/import" class="btn">Import repo</a>
221 </div>
222 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
223 <div style="flex:1">
224 <div style="font-size:15px;font-weight:600">Browse public repos</div>
225 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
226 See what others are building, fork what you like.
227 </div>
228 </div>
229 <a href="/explore" class="btn">Browse</a>
230 </div>
231 </div>
232 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px">
233 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
234 Push an existing project (preview)
235 </div>
236 <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.
237git remote add gluecron http://localhost:3000/{user.username}/&lt;your-repo&gt;.git
238git push -u gluecron main</code></pre>
239 </div>
f1ab587Claude240 </div>
241 ) : (
242 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; margin-bottom: 32px">
243 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
244 <div class="card" style="padding: 0; overflow: hidden">
245 {/* Health bar at top */}
246 <div
247 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
248 />
249 <div style="padding: 16px">
250 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
251 <div>
252 <h3 style="font-size: 16px; margin-bottom: 2px">
253 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
254 </h3>
255 {repo.description && (
256 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
257 {repo.description}
258 </p>
259 )}
260 </div>
261 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
262 <div
263 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
264 >
265 {healthGrade}
266 </div>
267 <div style="font-size: 10px; color: var(--text-muted)">
268 {healthScore}/100
269 </div>
270 </div>
271 </div>
272
273 <div style="display: flex; gap: 16px; font-size: 12px; color: var(--text-muted); margin-top: 8px">
274 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
275 <span>{"\u2606"} {repo.starCount}</span>
276 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
277 </div>
278
279 {ciConfig && ciConfig.commands.length > 0 && (
280 <div style="margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap">
281 {ciConfig.detected.slice(0, 3).map((d) => (
282 <span
283 class="badge"
284 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
285 >
286 {d}
287 </span>
288 ))}
289 </div>
290 )}
291
292 <div style="display: flex; gap: 6px; margin-top: 12px">
293 <a
294 href={`/${user.username}/${repo.name}/health`}
295 class="btn btn-sm"
296 style="font-size: 11px; padding: 2px 8px"
297 >
298 Health
299 </a>
300 <a
301 href={`/${user.username}/${repo.name}/dependencies`}
302 class="btn btn-sm"
303 style="font-size: 11px; padding: 2px 8px"
304 >
305 Deps
306 </a>
307 <a
308 href={`/${user.username}/${repo.name}/coupling`}
309 class="btn btn-sm"
310 style="font-size: 11px; padding: 2px 8px"
311 >
312 Insights
313 </a>
314 <a
315 href={`/${user.username}/${repo.name}/settings`}
316 class="btn btn-sm"
317 style="font-size: 11px; padding: 2px 8px"
318 >
319 Settings
320 </a>
321 </div>
322 </div>
323 </div>
324 ))}
325 </div>
326 )}
327
328 {/* ─── Activity Feed ─── */}
329 {recentActivity.length > 0 && (
330 <>
331 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
332 <div class="issue-list">
333 {recentActivity.map((a) => (
334 <div class="issue-item">
335 <div style="display: flex; gap: 8px; align-items: center">
336 <ActivityIcon action={a.action} />
337 <div>
338 <span style="font-size: 14px">
339 {formatAction(a.action)} in{" "}
340 <a href={`/${user.username}/${a.repoName}`}>
341 {a.repoName}
342 </a>
343 </span>
344 <div style="font-size: 12px; color: var(--text-muted)">
345 {formatRelative(a.createdAt)}
346 </div>
347 </div>
348 </div>
349 </div>
350 ))}
351 </div>
352 </>
353 )}
354
7a14837Claude355 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
356 <HealthCoach repoData={repoData} username={user.username} />
357
febd4f0Claude358 {/* ─── Live Activity (SSE) ─── */}
359 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
360
f1ab587Claude361 {/* ─── Quick Links ─── */}
362 <div style="margin-top: 32px; display: flex; gap: 16px; flex-wrap: wrap">
363 <a href="/explore" class="btn">Browse public repos</a>
364 <a href="/settings/tokens" class="btn">API tokens</a>
365 <a href="/settings/keys" class="btn">SSH keys</a>
366 </div>
367 </Layout>
368 );
369});
370
371// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
372
373dashboard.get(
374 "/:owner/:repo/settings/intelligence",
375 requireAuth,
376 async (c) => {
377 const { owner: ownerName, repo: repoName } = c.req.param();
378 const user = c.get("user")!;
379 const success = c.req.query("success");
380
381 return c.html(
382 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
383 <div style="max-width: 600px">
384 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
385 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
386 Control what gluecron does automatically when code is pushed to{" "}
387 <strong>{ownerName}/{repoName}</strong>.
388 </p>
389 {success && (
390 <div class="auth-success">{decodeURIComponent(success)}</div>
391 )}
392 <form
0316dbbClaude393 method="post"
f1ab587Claude394 action={`/${ownerName}/${repoName}/settings/intelligence`}
395 >
396 <ToggleSetting
397 name="auto_repair"
398 label="Auto-Repair"
399 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
400 defaultChecked={true}
401 />
402 <ToggleSetting
403 name="security_scan"
404 label="Security Scanning"
405 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
406 defaultChecked={true}
407 />
408 <ToggleSetting
409 name="health_score"
410 label="Health Score"
411 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
412 defaultChecked={true}
413 />
414 <ToggleSetting
415 name="push_analysis"
416 label="Push Risk Analysis"
417 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
418 defaultChecked={true}
419 />
420 <ToggleSetting
421 name="dep_analysis"
422 label="Dependency Analysis"
423 description="Build import graph, detect unused deps, find circular dependencies"
424 defaultChecked={true}
425 />
426 <ToggleSetting
427 name="gatetest"
428 label="GateTest Integration"
429 description="Send push events to GateTest for external security scanning"
430 defaultChecked={true}
431 />
432 <ToggleSetting
90fa787Claude433 name="deploy_webhook"
434 label="Auto-Deploy Webhook"
435 description="POST to your configured deploy webhook when pushing to the default branch"
f1ab587Claude436 defaultChecked={true}
437 />
438
439 <button
440 type="submit"
441 class="btn btn-primary"
442 style="margin-top: 12px"
443 >
444 Save settings
445 </button>
446 </form>
447 </div>
448 </Layout>
449 );
450 }
451);
452
453dashboard.post(
454 "/:owner/:repo/settings/intelligence",
455 requireAuth,
456 async (c) => {
457 const { owner: ownerName, repo: repoName } = c.req.param();
458 // In production, these would be saved to DB per-repo
459 // For now, acknowledge the settings
460 return c.redirect(
461 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
462 );
463 }
464);
465
466// ─── PUSH LOG ────────────────────────────────────────────────
467
468dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
469 const { owner, repo } = c.req.param();
470 const user = c.get("user");
471
472 if (!(await repoExists(owner, repo))) return c.notFound();
473 const ref = (await getDefaultBranch(owner, repo)) || "main";
474 const commits = await listCommits(owner, repo, ref, 30);
475
476 return c.html(
477 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
478 <div style="max-width: 900px">
479 <h2 style="margin-bottom: 4px">Push Log</h2>
480 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
481 Every push analyzed in real-time — risk scores, repairs, security alerts
482 </p>
483 <div class="issue-list">
484 {commits.map((commit) => {
485 // Determine if this was an auto-repair commit
486 const isRepair =
487 commit.author === "gluecron[bot]" ||
488 commit.message.includes("auto-repair");
489 const isRollback = commit.message.startsWith("revert: rollback");
490
491 return (
492 <div class="issue-item" style="flex-direction: column; align-items: stretch">
493 <div style="display: flex; justify-content: space-between; align-items: start">
494 <div style="display: flex; gap: 8px; align-items: start">
495 {isRepair ? (
496 <span
497 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
498 title="Auto-repair"
499 >
500 {"⚡"}
501 </span>
502 ) : isRollback ? (
503 <span
504 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
505 title="Rollback"
506 >
507 {"↩"}
508 </span>
509 ) : (
510 <span
511 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
512 >
513 {"→"}
514 </span>
515 )}
516 <div>
517 <a
518 href={`/${owner}/${repo}/commit/${commit.sha}`}
519 style="font-weight: 600; font-size: 14px"
520 >
521 {commit.message}
522 </a>
523 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
524 {commit.author} —{" "}
525 {new Date(commit.date).toLocaleString("en-US", {
526 month: "short",
527 day: "numeric",
528 hour: "2-digit",
529 minute: "2-digit",
530 })}
531 </div>
532 </div>
533 </div>
534 <a
535 href={`/${owner}/${repo}/commit/${commit.sha}`}
536 class="commit-sha"
537 >
538 {commit.sha.slice(0, 7)}
539 </a>
540 </div>
541 {isRepair && (
542 <div
543 style="margin-top: 8px; padding: 8px 12px; background: rgba(63, 185, 80, 0.1); border-radius: var(--radius); font-size: 12px; color: var(--green)"
544 >
545 Automatically repaired by gluecron
546 </div>
547 )}
548 </div>
549 );
550 })}
551 </div>
552 </div>
553 </Layout>
554 );
555});
556
557// ─── COMPONENTS ──────────────────────────────────────────────
558
7a14837Claude559/**
560 * Pure helper: pick the bottom-N repos by health score and return a
561 * prioritized "fix this next" plan. Health=0 repos (couldn't be
562 * computed) are excluded so the coach doesn't recommend ghost repos.
563 *
564 * Exported under __test for unit testing without touching the DB.
565 */
566export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
567 repoData: T[],
568 topN = 3
569): T[] {
570 return repoData
571 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
572 .sort((a, b) => a.healthScore - b.healthScore)
573 .slice(0, topN);
574}
575
576/** Module-scoped color picker for grade chips. Mirrors the inner
577 * `gradeColor` defined in the request handler scope, exposed at module
578 * level so HealthCoach (also module-scope) can reach it. */
579function moduleGradeColor(grade: string): string {
580 if (grade === "A+" || grade === "A") return "var(--green)";
581 if (grade === "B") return "#58a6ff";
582 if (grade === "C") return "var(--yellow)";
583 if (grade === "?") return "var(--text-muted)";
584 return "var(--red)";
585}
586
587const HealthCoach = ({
588 repoData,
589 username,
590}: {
591 repoData: Array<{
592 repo: { name: string; description: string | null };
593 healthScore: number;
594 healthGrade: string;
595 }>;
596 username: string;
597}) => {
598 const picks = pickRepoCoachPicks(repoData, 3);
599 if (picks.length === 0) {
600 return (
601 <div
602 class="card"
603 style="margin-bottom: 32px; padding: 16px; background: rgba(63,185,80,0.08); border-color: var(--green)"
604 >
605 <h3 style="margin: 0 0 4px; font-size: 15px">
606 {"✨"} AI Health Coach
607 </h3>
608 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
609 All your repos are healthy (score &ge; 90). Nothing to triage.
610 </p>
611 </div>
612 );
613 }
614 return (
615 <div
616 class="card"
617 style="margin-bottom: 32px; padding: 0; overflow: hidden"
618 >
619 <div
620 style="padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
621 >
622 <div>
623 <h3 style="margin: 0; font-size: 15px">
624 {"✨"} AI Health Coach
625 </h3>
626 <p
627 style="margin: 4px 0 0; color: var(--text-muted); font-size: 12px"
628 >
629 Top {picks.length} repos that would benefit from attention
630 this week.
631 </p>
632 </div>
633 </div>
634 <ul style="list-style: none; margin: 0; padding: 0">
635 {picks.map((p) => (
636 <li
637 style="padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px"
638 >
639 <div
640 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
641 >
642 {p.healthGrade}
643 </div>
644 <div style="flex: 1; min-width: 0">
645 <a
646 href={`/${username}/${p.repo.name}`}
647 style="font-weight: 500"
648 >
649 {p.repo.name}
650 </a>
651 <div
652 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
653 >
654 Health score {p.healthScore}/100 — open the repo to see
655 breakdown + AI suggestions.
656 </div>
657 </div>
658 <a
659 href={`/${username}/${p.repo.name}/explain`}
660 class="btn"
661 style="font-size: 12px; padding: 4px 10px"
662 title="Run AI explain on this repo"
663 >
664 Coach me
665 </a>
666 </li>
667 ))}
668 </ul>
669 </div>
670 );
671};
672
f1ab587Claude673const StatBox = ({
674 label,
675 value,
676 color,
677}: {
678 label: string;
679 value: string;
680 color: string;
681}) => (
682 <div
683 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; text-align: center"
684 >
685 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
686 {value}
687 </div>
688 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
689 {label}
690 </div>
691 </div>
692);
693
694const ToggleSetting = ({
695 name,
696 label,
697 description,
698 defaultChecked,
699}: {
700 name: string;
701 label: string;
702 description: string;
703 defaultChecked: boolean;
704}) => (
705 <div
706 style="display: flex; justify-content: space-between; align-items: start; padding: 16px 0; border-bottom: 1px solid var(--border)"
707 >
708 <div style="flex: 1">
709 <div style="font-size: 15px; font-weight: 600">{label}</div>
710 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
711 {description}
712 </div>
713 </div>
714 <label class="toggle-switch">
2228c49copilot-swe-agent[bot]715 <input type="checkbox" name={name} value="on" checked={defaultChecked} aria-label={label} />
f1ab587Claude716 <span class="toggle-slider" />
717 </label>
718 </div>
719);
720
721const ActivityIcon = ({ action }: { action: string }) => {
722 const icons: Record<string, string> = {
723 push: "→",
724 issue_open: "\u25CB",
725 issue_close: "\u2713",
726 pr_open: "\u25CB",
727 pr_merge: "\u2B8C",
728 star: "\u2605",
729 fork: "\u2442",
730 comment: "\u{1F4AC}",
731 };
732 return (
733 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
734 {icons[action] || "•"}
735 </span>
736 );
737};
738
739function formatAction(action: string): string {
740 const labels: Record<string, string> = {
741 push: "Pushed code",
742 issue_open: "Opened issue",
743 issue_close: "Closed issue",
744 pr_open: "Opened pull request",
745 pr_merge: "Merged pull request",
746 star: "Starred",
747 fork: "Forked",
748 comment: "Commented",
749 };
750 return labels[action] || action;
751}
752
753function formatRelative(date: Date | string): string {
754 const d = typeof date === "string" ? new Date(date) : date;
755 const now = new Date();
756 const diffMs = now.getTime() - d.getTime();
757 const diffMins = Math.floor(diffMs / 60000);
758 if (diffMins < 1) return "just now";
759 if (diffMins < 60) return `${diffMins}m ago`;
760 const diffHours = Math.floor(diffMins / 60);
761 if (diffHours < 24) return `${diffHours}h ago`;
762 const diffDays = Math.floor(diffHours / 24);
763 if (diffDays < 30) return `${diffDays}d ago`;
764 return d.toLocaleDateString("en-US", {
765 month: "short",
766 day: "numeric",
767 });
768}
769
770export default dashboard;