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.tsxBlame449 lines · 1 contributor
3ef4c9dClaude1/**
2 * Dashboard — the authed user's home page.
3 *
4 * Shows:
5 * - Unread notifications (top 5 with link to full inbox)
6 * - PRs awaiting your review
7 * - PRs you authored that are open
8 * - Issues assigned to you (currently "authored by you" until assignments land)
9 * - Recent activity across your repos
10 * - Your repositories
11 * - Gate health summary across all your repos
12 *
13 * Rendered at `/dashboard`. The `/` route still calls this for logged-in users.
14 */
15
16import { Hono } from "hono";
17import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
18import { db } from "../db";
19import {
20 activityFeed,
21 gateRuns,
22 issues,
23 notifications,
24 pullRequests,
25 repositories,
26 users,
27} from "../db/schema";
28import { Layout } from "../views/layout";
29import { RepoCard } from "../views/components";
30import { requireAuth, softAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import { getUnreadCount } from "../lib/unread";
33
34const dashboard = new Hono<AuthEnv>();
35dashboard.use("*", softAuth);
36
37function relTime(d: Date | string): string {
38 const t = typeof d === "string" ? new Date(d) : d;
39 const diffMs = Date.now() - t.getTime();
40 const mins = Math.floor(diffMs / 60000);
41 if (mins < 1) return "just now";
42 if (mins < 60) return `${mins}m ago`;
43 const hrs = Math.floor(mins / 60);
44 if (hrs < 24) return `${hrs}h ago`;
45 const days = Math.floor(hrs / 24);
46 if (days < 30) return `${days}d ago`;
47 return t.toLocaleDateString();
48}
49
50export async function renderDashboard(c: any) {
51 const user = c.get("user")!;
52 const unreadCount = await getUnreadCount(user.id);
53
54 // User's repositories
55 const myRepos = await db
56 .select()
57 .from(repositories)
58 .where(eq(repositories.ownerId, user.id))
59 .orderBy(desc(repositories.updatedAt))
60 .limit(12);
61
62 const myRepoIds = myRepos.map((r) => r.id);
63
64 // Unread notifications (top 5)
65 let recentNotifications: Array<{
66 id: string;
67 kind: string;
68 title: string;
69 url: string | null;
70 createdAt: Date;
71 }> = [];
72 try {
73 recentNotifications = await db
74 .select({
75 id: notifications.id,
76 kind: notifications.kind,
77 title: notifications.title,
78 url: notifications.url,
79 createdAt: notifications.createdAt,
80 })
81 .from(notifications)
82 .where(
83 and(eq(notifications.userId, user.id), isNull(notifications.readAt))
84 )
85 .orderBy(desc(notifications.createdAt))
86 .limit(5);
87 } catch {
88 /* ignore */
89 }
90
91 // Open PRs you authored
92 let myPrs: Array<{
93 id: string;
94 number: number;
95 title: string;
96 state: string;
97 repoName: string;
98 repoOwner: string;
99 createdAt: Date;
100 }> = [];
101 try {
102 myPrs = await db
103 .select({
104 id: pullRequests.id,
105 number: pullRequests.number,
106 title: pullRequests.title,
107 state: pullRequests.state,
108 repoName: repositories.name,
109 repoOwner: users.username,
110 createdAt: pullRequests.createdAt,
111 })
112 .from(pullRequests)
113 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
114 .innerJoin(users, eq(repositories.ownerId, users.id))
115 .where(
116 and(
117 eq(pullRequests.authorId, user.id),
118 eq(pullRequests.state, "open")
119 )
120 )
121 .orderBy(desc(pullRequests.updatedAt))
122 .limit(10);
123 } catch {
124 /* ignore */
125 }
126
127 // PRs in your repos awaiting review (open, not authored by you)
128 let reviewablePrs: Array<{
129 id: string;
130 number: number;
131 title: string;
132 repoName: string;
133 repoOwner: string;
134 createdAt: Date;
135 }> = [];
136 if (myRepoIds.length > 0) {
137 try {
138 reviewablePrs = await db
139 .select({
140 id: pullRequests.id,
141 number: pullRequests.number,
142 title: pullRequests.title,
143 repoName: repositories.name,
144 repoOwner: users.username,
145 createdAt: pullRequests.createdAt,
146 })
147 .from(pullRequests)
148 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
149 .innerJoin(users, eq(repositories.ownerId, users.id))
150 .where(
151 and(
152 inArray(pullRequests.repositoryId, myRepoIds),
153 eq(pullRequests.state, "open")
154 )
155 )
156 .orderBy(desc(pullRequests.updatedAt))
157 .limit(10);
158 } catch {
159 /* ignore */
160 }
161 }
162
163 // Issues you authored that are still open
164 let myIssues: Array<{
165 id: string;
166 number: number;
167 title: string;
168 repoName: string;
169 repoOwner: string;
170 createdAt: Date;
171 }> = [];
172 try {
173 myIssues = await db
174 .select({
175 id: issues.id,
176 number: issues.number,
177 title: issues.title,
178 repoName: repositories.name,
179 repoOwner: users.username,
180 createdAt: issues.createdAt,
181 })
182 .from(issues)
183 .innerJoin(repositories, eq(issues.repositoryId, repositories.id))
184 .innerJoin(users, eq(repositories.ownerId, users.id))
185 .where(and(eq(issues.authorId, user.id), eq(issues.state, "open")))
186 .orderBy(desc(issues.updatedAt))
187 .limit(10);
188 } catch {
189 /* ignore */
190 }
191
192 // Recent activity across user's repos
193 let recentActivity: Array<{
194 id: string;
195 action: string;
196 repoName: string;
197 repoOwner: string;
198 createdAt: Date;
199 }> = [];
200 if (myRepoIds.length > 0) {
201 try {
202 recentActivity = await db
203 .select({
204 id: activityFeed.id,
205 action: activityFeed.action,
206 repoName: repositories.name,
207 repoOwner: users.username,
208 createdAt: activityFeed.createdAt,
209 })
210 .from(activityFeed)
211 .innerJoin(repositories, eq(activityFeed.repositoryId, repositories.id))
212 .innerJoin(users, eq(repositories.ownerId, users.id))
213 .where(inArray(activityFeed.repositoryId, myRepoIds))
214 .orderBy(desc(activityFeed.createdAt))
215 .limit(15);
216 } catch {
217 /* ignore */
218 }
219 }
220
221 // Gate health across your repos (last 20 runs)
222 let gateHealth: Array<{
223 gateName: string;
224 status: string;
225 repoName: string;
226 repoOwner: string;
227 createdAt: Date;
228 summary: string | null;
229 }> = [];
230 if (myRepoIds.length > 0) {
231 try {
232 gateHealth = await db
233 .select({
234 gateName: gateRuns.gateName,
235 status: gateRuns.status,
236 repoName: repositories.name,
237 repoOwner: users.username,
238 createdAt: gateRuns.createdAt,
239 summary: gateRuns.summary,
240 })
241 .from(gateRuns)
242 .innerJoin(repositories, eq(gateRuns.repositoryId, repositories.id))
243 .innerJoin(users, eq(repositories.ownerId, users.id))
244 .where(inArray(gateRuns.repositoryId, myRepoIds))
245 .orderBy(desc(gateRuns.createdAt))
246 .limit(10);
247 } catch {
248 /* ignore */
249 }
250 }
251
252 const greenCount = gateHealth.filter(
253 (g) => g.status === "passed" || g.status === "repaired" || g.status === "skipped"
254 ).length;
255 const redCount = gateHealth.filter((g) => g.status === "failed").length;
256
257 return c.html(
258 <Layout title="Dashboard" user={user} notificationCount={unreadCount}>
259 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
260 <h2>Welcome back, {user.displayName || user.username}</h2>
261 <a href="/new" class="btn btn-primary">
262 + New repository
263 </a>
264 </div>
265
266 <div class="dashboard-grid">
267 {/* Left column */}
268 <div>
269 <div class="dashboard-section">
270 <h3>
271 Needs your attention
272 <a href="/notifications">view all</a>
273 </h3>
274 <div class="panel">
275 {recentNotifications.length === 0 ? (
276 <div class="panel-empty">Inbox zero. Nice work.</div>
277 ) : (
278 recentNotifications.map((n) => (
279 <div class="panel-item">
280 <div class="dot blue"></div>
281 <div style="flex: 1">
282 {n.url ? (
283 <a href={n.url}>{n.title}</a>
284 ) : (
285 <span>{n.title}</span>
286 )}
287 <div class="meta">
288 {n.kind} · {relTime(n.createdAt)}
289 </div>
290 </div>
291 </div>
292 ))
293 )}
294 </div>
295 </div>
296
297 <div class="dashboard-section">
298 <h3>PRs awaiting review in your repos</h3>
299 <div class="panel">
300 {reviewablePrs.length === 0 ? (
301 <div class="panel-empty">No open PRs in your repositories.</div>
302 ) : (
303 reviewablePrs.map((pr) => (
304 <div class="panel-item">
305 <div class="dot green"></div>
306 <div style="flex: 1">
307 <a href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}>
308 {pr.title}
309 </a>
310 <div class="meta">
311 {pr.repoOwner}/{pr.repoName}#{pr.number} · opened{" "}
312 {relTime(pr.createdAt)}
313 </div>
314 </div>
315 </div>
316 ))
317 )}
318 </div>
319 </div>
320
321 <div class="dashboard-section">
322 <h3>Your open PRs</h3>
323 <div class="panel">
324 {myPrs.length === 0 ? (
325 <div class="panel-empty">You have no open PRs.</div>
326 ) : (
327 myPrs.map((pr) => (
328 <div class="panel-item">
329 <div class="dot blue"></div>
330 <div style="flex: 1">
331 <a href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}>
332 {pr.title}
333 </a>
334 <div class="meta">
335 {pr.repoOwner}/{pr.repoName}#{pr.number} ·{" "}
336 {relTime(pr.createdAt)}
337 </div>
338 </div>
339 </div>
340 ))
341 )}
342 </div>
343 </div>
344
345 <div class="dashboard-section">
346 <h3>Your open issues</h3>
347 <div class="panel">
348 {myIssues.length === 0 ? (
349 <div class="panel-empty">No open issues.</div>
350 ) : (
351 myIssues.map((i) => (
352 <div class="panel-item">
353 <div class="dot yellow"></div>
354 <div style="flex: 1">
355 <a href={`/${i.repoOwner}/${i.repoName}/issues/${i.number}`}>
356 {i.title}
357 </a>
358 <div class="meta">
359 {i.repoOwner}/{i.repoName}#{i.number} ·{" "}
360 {relTime(i.createdAt)}
361 </div>
362 </div>
363 </div>
364 ))
365 )}
366 </div>
367 </div>
368 </div>
369
370 {/* Right column */}
371 <div>
372 <div class="dashboard-section">
373 <h3>Gate health</h3>
374 <div class="panel" style="padding: 16px">
375 <div style="display: flex; gap: 12px; margin-bottom: 12px">
376 <div style="flex: 1; text-align: center; padding: 8px; background: rgba(63, 185, 80, 0.1); border-radius: var(--radius)">
377 <div style="font-size: 24px; font-weight: 700; color: var(--green)">
378 {greenCount}
379 </div>
380 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">
381 green
382 </div>
383 </div>
384 <div style="flex: 1; text-align: center; padding: 8px; background: rgba(248, 81, 73, 0.1); border-radius: var(--radius)">
385 <div style="font-size: 24px; font-weight: 700; color: var(--red)">
386 {redCount}
387 </div>
388 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">
389 failed
390 </div>
391 </div>
392 </div>
393 <div style="font-size: 12px; color: var(--text-muted); text-align: center">
394 Last 10 gate runs across your repos
395 </div>
396 </div>
397 </div>
398
399 <div class="dashboard-section">
400 <h3>Recent activity</h3>
401 <div class="panel">
402 {recentActivity.length === 0 ? (
403 <div class="panel-empty">No activity yet.</div>
404 ) : (
405 recentActivity.map((a) => (
406 <div class="panel-item">
407 <div
408 class={`dot ${a.action === "push" ? "green" : a.action === "pr_merge" ? "blue" : "yellow"}`}
409 ></div>
410 <div style="flex: 1">
411 <a href={`/${a.repoOwner}/${a.repoName}`}>
412 {a.repoOwner}/{a.repoName}
413 </a>{" "}
414 <span style="color: var(--text-muted)">{a.action}</span>
415 <div class="meta">{relTime(a.createdAt)}</div>
416 </div>
417 </div>
418 ))
419 )}
420 </div>
421 </div>
422 </div>
423 </div>
424
425 <div class="dashboard-section" style="margin-top: 32px">
426 <h3>
427 Your repositories
428 <a href={`/${user.username}`}>view all</a>
429 </h3>
430 {myRepos.length === 0 ? (
431 <div class="empty-state">
432 <h2>No repositories yet</h2>
433 <p>Create your first repository to get started.</p>
434 </div>
435 ) : (
436 <div class="card-grid">
437 {myRepos.map((repo) => (
438 <RepoCard repo={repo} ownerName={user.username} />
439 ))}
440 </div>
441 )}
442 </div>
443 </Layout>
444 );
445}
446
447dashboard.get("/dashboard", requireAuth, (c) => renderDashboard(c));
448
449export default dashboard;