feat(dashboard): derive Recent Activity from primary tables, not the starved activity_feed #5436
2 changed files+265−44
Addedsrc/lib/recent-activity.ts+202−0View fileUnifiedSplit
@@ -0,0 +1,202 @@
1/**
2 * Derived recent-activity feed for the dashboard.
3 *
4 * The `activity_feed` table is only written by a handful of niche paths
5 * (forks, templates, the Claude integration) — repo creation, PR opens,
6 * merges, and issues never wrote a row, so the dashboard feed rendered one
7 * or two lines while the platform was full of real activity. Partial
8 * coverage that renders as complete: the feed LOOKED live and proved dead.
9 *
10 * Instead of chasing every write path, derive the feed from the primary
11 * tables that already record everything with timestamps. `activity_feed`
12 * rows are still merged in so the niche events keep appearing.
13 */
14
15import { and, desc, eq, isNull, isNotNull } from "drizzle-orm";
16import { db } from "../db";
17import {
18 activityFeed,
19 issues,
20 pullRequests,
21 repositories,
22 users,
23} from "../db/schema";
24
25export interface RecentActivityItem {
26 /** Action slugs shared with the dashboard's formatAction/ActivityIcon. */
27 action: string;
28 repoName: string;
29 ownerUsername: string;
30 /** Issue/PR number when the event has a deep link. */
31 number?: number | null;
32 /** Issue/PR title, or for a collapsed burst the list of repo names. */
33 title?: string | null;
34 createdAt: Date;
35 /** >1 = a collapsed burst (e.g. a bulk import's repo creations). */
36 count?: number;
37}
38
39/** Bursts of the same action closer together than this collapse into one row. */
40const BURST_WINDOW_MS = 6 * 60 * 60 * 1000;
41
42/** Actions where a burst is one gesture (bulk import), not N separate ones. */
43const COLLAPSIBLE = new Set(["repo_create"]);
44
45const PER_SOURCE_LIMIT = 40;
46
47export async function buildRecentActivity(
48 userId: string,
49 username: string,
50 limit = 20
51): Promise<RecentActivityItem[]> {
52 const [created, prsOpened, prsMerged, issuesOpened, feedRows] =
53 await Promise.all([
54 // Repos created in the user's own namespace. Org-owned repos are
55 // excluded because their URL needs the org slug, not the username.
56 db
57 .select({ name: repositories.name, createdAt: repositories.createdAt })
58 .from(repositories)
59 .where(
60 and(eq(repositories.ownerId, userId), isNull(repositories.orgId))
61 )
62 .orderBy(desc(repositories.createdAt))
63 .limit(PER_SOURCE_LIMIT),
64
65 db
66 .select({
67 number: pullRequests.number,
68 title: pullRequests.title,
69 createdAt: pullRequests.createdAt,
70 repoName: repositories.name,
71 ownerUsername: users.username,
72 })
73 .from(pullRequests)
74 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
75 .innerJoin(users, eq(repositories.ownerId, users.id))
76 .where(eq(pullRequests.authorId, userId))
77 .orderBy(desc(pullRequests.createdAt))
78 .limit(PER_SOURCE_LIMIT),
79
80 db
81 .select({
82 number: pullRequests.number,
83 title: pullRequests.title,
84 mergedAt: pullRequests.mergedAt,
85 repoName: repositories.name,
86 ownerUsername: users.username,
87 })
88 .from(pullRequests)
89 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
90 .innerJoin(users, eq(repositories.ownerId, users.id))
91 .where(
92 and(
93 eq(pullRequests.mergedBy, userId),
94 isNotNull(pullRequests.mergedAt)
95 )
96 )
97 .orderBy(desc(pullRequests.mergedAt))
98 .limit(PER_SOURCE_LIMIT),
99
100 db
101 .select({
102 number: issues.number,
103 title: issues.title,
104 createdAt: issues.createdAt,
105 repoName: repositories.name,
106 ownerUsername: users.username,
107 })
108 .from(issues)
109 .innerJoin(repositories, eq(issues.repositoryId, repositories.id))
110 .innerJoin(users, eq(repositories.ownerId, users.id))
111 .where(eq(issues.authorId, userId))
112 .orderBy(desc(issues.createdAt))
113 .limit(PER_SOURCE_LIMIT),
114
115 // Keep the events only the table knows about (forks, templates, …).
116 db
117 .select({
118 action: activityFeed.action,
119 createdAt: activityFeed.createdAt,
120 repoName: repositories.name,
121 ownerUsername: users.username,
122 })
123 .from(activityFeed)
124 .innerJoin(repositories, eq(activityFeed.repositoryId, repositories.id))
125 .innerJoin(users, eq(repositories.ownerId, users.id))
126 .where(eq(activityFeed.userId, userId))
127 .orderBy(desc(activityFeed.createdAt))
128 .limit(PER_SOURCE_LIMIT),
129 ]);
130
131 const merged: RecentActivityItem[] = [
132 ...created.map((r) => ({
133 action: "repo_create",
134 repoName: r.name,
135 ownerUsername: username,
136 createdAt: r.createdAt,
137 })),
138 ...prsOpened.map((p) => ({
139 action: "pr_open",
140 repoName: p.repoName,
141 ownerUsername: p.ownerUsername,
142 number: p.number,
143 title: p.title,
144 createdAt: p.createdAt,
145 })),
146 ...prsMerged.map((p) => ({
147 action: "pr_merge",
148 repoName: p.repoName,
149 ownerUsername: p.ownerUsername,
150 number: p.number,
151 title: p.title,
152 createdAt: p.mergedAt!,
153 })),
154 ...issuesOpened.map((i) => ({
155 action: "issue_open",
156 repoName: i.repoName,
157 ownerUsername: i.ownerUsername,
158 number: i.number,
159 title: i.title,
160 createdAt: i.createdAt,
161 })),
162 // The dashboard already derives pr_merge rows from pull_requests, and the
163 // Claude integration writes the same event to activity_feed — drop the
164 // table's copy rather than showing the merge twice.
165 ...feedRows
166 .filter((f) => f.action !== "pr_merge")
167 .map((f) => ({
168 action: f.action,
169 repoName: f.repoName,
170 ownerUsername: f.ownerUsername,
171 createdAt: f.createdAt,
172 })),
173 ];
174
175 merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
176
177 // Collapse bursts: a bulk import creates dozens of repos in one gesture,
178 // and 37 identical "Created repository" rows would BE the whole feed.
179 const collapsed: RecentActivityItem[] = [];
180 for (const item of merged) {
181 const prev = collapsed[collapsed.length - 1];
182 if (
183 prev &&
184 COLLAPSIBLE.has(item.action) &&
185 prev.action === item.action &&
186 prev.createdAt.getTime() - item.createdAt.getTime() < BURST_WINDOW_MS
187 ) {
188 prev.count = (prev.count ?? 1) + 1;
189 // Show the first few names of the burst; the count carries the rest.
190 if (prev.count <= 4) {
191 prev.title = prev.title
192 ? `${prev.title}, ${item.repoName}`
193 : `${prev.repoName}, ${item.repoName}`;
194 }
195 continue;
196 }
197 collapsed.push({ ...item });
198 if (collapsed.length >= limit) break;
199 }
200
201 return collapsed.slice(0, limit);
202}
Modifiedsrc/routes/dashboard.tsx+63−44View fileUnifiedSplit
@@ -48,6 +48,10 @@ import {
4848 type AiSavingsLifetimeReport,
4949} from "../lib/ai-hours-saved";
5050import { totalOpenIssues } from "../lib/issue-counts";
51import {
52 buildRecentActivity,
53 type RecentActivityItem,
54} from "../lib/recent-activity";
5155
5256// ─── AI Activity — Last Hour ─────────────────────────────────────────────────
5357
@@ -377,36 +381,13 @@ dashboard.get("/dashboard", requireAuth, async (c) => {
377381 fetchAiActivityLastHour(user.id, repoIds, repoMap),
378382 ]);
379383
380 // Get recent activity
381 let recentActivity: Array<{
382 action: string;
383 repoName: string;
384 metadata: string | null;
385 createdAt: Date;
386 }> = [];
387
384 // Recent activity, derived from the primary tables (repos, PRs, issues)
385 // rather than the sparsely-written activity_feed table — see
386 // src/lib/recent-activity.ts for why the table alone rendered a feed of
387 // one line while 37 repos were being imported.
388 let recentActivity: RecentActivityItem[] = [];
388389 try {
389 const repoIds = repos.map((r) => r.id);
390 if (repoIds.length > 0) {
391 const activity = await db
392 .select({
393 action: activityFeed.action,
394 metadata: activityFeed.metadata,
395 createdAt: activityFeed.createdAt,
396 repoId: activityFeed.repositoryId,
397 })
398 .from(activityFeed)
399 .where(eq(activityFeed.userId, user.id))
400 .orderBy(desc(activityFeed.createdAt))
401 .limit(20);
402
403 recentActivity = activity.map((a) => ({
404 action: a.action,
405 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
406 metadata: a.metadata,
407 createdAt: a.createdAt,
408 }));
409 }
390 recentActivity = await buildRecentActivity(user.id, user.username, 20);
410391 } catch {
411392 // DB not required for dashboard
412393 }
@@ -883,24 +864,62 @@ git push -u gluecron main</code></pre>
883864 <>
884865 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
885866 <div class="issue-list">
886 {recentActivity.map((a) => (
887 <div class="issue-item">
888 <div style="display: flex; gap: var(--space-2); align-items: center">
889 <ActivityIcon action={a.action} />
890 <div>
891 <span style="font-size: 14px">
892 {formatAction(a.action)} in{" "}
893 <a href={`/${user.username}/${a.repoName}`}>
894 {a.repoName}
895 </a>
896 </span>
897 <div style="font-size: 12px; color: var(--text-muted)">
898 {formatRelative(a.createdAt)}
867 {recentActivity.map((a) => {
868 const repoUrl = `/${a.ownerUsername}/${a.repoName}`;
869 const deepUrl = a.number
870 ? a.action.startsWith("pr")
871 ? `${repoUrl}/pulls/${a.number}`
872 : `${repoUrl}/issues/${a.number}`
873 : null;
874 return (
875 <div class="issue-item">
876 <div style="display: flex; gap: var(--space-2); align-items: center">
877 <ActivityIcon action={a.action} />
878 <div style="min-width: 0">
879 <span style="font-size: 14px">
880 {a.count && a.count > 1 ? (
881 <>
882 Created {a.count} repositories{" "}
883 <span style="color: var(--text-muted)">
884 ({a.title}
885 {a.count > 4 ? `, +${a.count - 4} more` : ""})
886 </span>
887 </>
888 ) : (
889 <>
890 {formatAction(a.action)}
891 {deepUrl && (
892 <>
893 {" "}
894 <a href={deepUrl}>#{a.number}</a>
895 </>
896 )}{" "}
897 in <a href={repoUrl}>{a.repoName}</a>
898 </>
899 )}
900 </span>
901 {!(a.count && a.count > 1) && a.title && (
902 <div style="font-size: 13px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 560px">
903 {deepUrl ? (
904 <a
905 href={deepUrl}
906 style="color: inherit; text-decoration: none"
907 >
908 {a.title}
909 </a>
910 ) : (
911 a.title
912 )}
913 </div>
914 )}
915 <div style="font-size: 12px; color: var(--text-muted)">
916 {formatRelative(a.createdAt)}
917 </div>
899918 </div>
900919 </div>
901920 </div>
902 </div>
903 ))}
921 );
922 })}
904923 </div>
905924 </>
906925 )}
907926
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts