From 859bbe692893a5129e51c69eafcc189f50122cd4 Mon Sep 17 00:00:00 2001 From: KrishKyada Date: Wed, 20 May 2026 00:21:34 +0530 Subject: [PATCH] Feat(activity): add Recent Activity component to display GitHub events --- src/app/api/metrics/activity/route.ts | 325 ++++++++++++++++++++++++++ src/app/dashboard/page.tsx | 6 + src/components/RecentActivity.tsx | 239 +++++++++++++++++++ 3 files changed, 570 insertions(+) create mode 100644 src/app/api/metrics/activity/route.ts create mode 100644 src/components/RecentActivity.tsx diff --git a/src/app/api/metrics/activity/route.ts b/src/app/api/metrics/activity/route.ts new file mode 100644 index 0000000..8d70348 --- /dev/null +++ b/src/app/api/metrics/activity/route.ts @@ -0,0 +1,325 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { + getAccountToken, + getAllAccounts, +} from "@/lib/github-accounts"; +import { GITHUB_API, fetchUserEvents } from "@/lib/github"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +type ActivityType = + | "push" + | "pull_request" + | "issue" + | "release" + | "other"; + +interface ActivityItem { + id: string; + type: ActivityType; + createdAt: string; + title: string; + subtitle: string; + repo: string; + url: string; +} + +interface RawEvent { + id: string; + type: string; + created_at: string; + repo?: { name?: string }; + payload?: { + ref?: string; + head?: string; + action?: string; + commits?: Array<{ sha?: string }>; + pull_request?: { + html_url?: string; + number?: number; + title?: string; + merged?: boolean; + }; + issue?: { + html_url?: string; + number?: number; + title?: string; + }; + release?: { + html_url?: string; + tag_name?: string; + name?: string; + }; + }; +} + +const SUPPORTED_EVENT_TYPES = new Set([ + "PushEvent", + "PullRequestEvent", + "IssuesEvent", + "ReleaseEvent", +]); + +function getRepoUrl(repoName: string): string { + return `https://github.com/${repoName}`; +} + +function capitalize(value: string): string { + return value.length > 0 + ? value[0].toUpperCase() + value.slice(1) + : "Updated"; +} + +function formatActivity(event: RawEvent): ActivityItem | null { + const repoName = event.repo?.name; + + if (!repoName || !SUPPORTED_EVENT_TYPES.has(event.type)) { + return null; + } + + if (event.type === "PushEvent") { + const commitCount = event.payload?.commits?.length ?? 0; + const rawRef = event.payload?.ref ?? ""; + const branch = rawRef.replace("refs/heads/", "") || "default branch"; + const plural = commitCount === 1 ? "" : "s"; + + return { + id: event.id, + type: "push", + createdAt: event.created_at, + title: `Pushed ${commitCount} commit${plural} to ${branch}`, + subtitle: repoName, + repo: repoName, + url: event.payload?.head + ? `https://github.com/${repoName}/commit/${event.payload.head}` + : getRepoUrl(repoName), + }; + } + + if (event.type === "PullRequestEvent") { + const action = event.payload?.action ?? "updated"; + const pr = event.payload?.pull_request; + const number = pr?.number ? `#${pr.number}` : "PR"; + const wasMerged = action === "closed" && pr?.merged === true; + const actionText = wasMerged ? "Merged" : capitalize(action); + + return { + id: event.id, + type: "pull_request", + createdAt: event.created_at, + title: `${actionText} pull request ${number}`, + subtitle: pr?.title ?? repoName, + repo: repoName, + url: pr?.html_url ?? getRepoUrl(repoName), + }; + } + + if (event.type === "IssuesEvent") { + const action = event.payload?.action ?? "updated"; + const issue = event.payload?.issue; + const number = issue?.number ? `#${issue.number}` : "Issue"; + const actionText = capitalize(action); + + return { + id: event.id, + type: "issue", + createdAt: event.created_at, + title: `${actionText} issue ${number}`, + subtitle: issue?.title ?? repoName, + repo: repoName, + url: issue?.html_url ?? getRepoUrl(repoName), + }; + } + + if (event.type === "ReleaseEvent") { + const action = event.payload?.action ?? "published"; + const release = event.payload?.release; + const tag = release?.tag_name ?? "release"; + const actionText = capitalize(action); + + return { + id: event.id, + type: "release", + createdAt: event.created_at, + title: `${actionText} ${tag}`, + subtitle: release?.name ?? repoName, + repo: repoName, + url: release?.html_url ?? getRepoUrl(repoName), + }; + } + + return null; +} + +async function fetchFormattedActivity(token: string): Promise { + const events = (await fetchUserEvents(token)) as RawEvent[]; + + return events + .map(formatActivity) + .filter((item): item is ActivityItem => item !== null) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); +} + +async function fetchPublicEvents( + token: string, + githubLogin: string +): Promise { + const response = await fetch( + `${GITHUB_API}/users/${encodeURIComponent(githubLogin)}/events/public?per_page=100`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!response.ok) { + throw new Error("GitHub API error"); + } + + return (await response.json()) as RawEvent[]; +} + +async function fetchFormattedActivityWithFallback( + token: string, + githubLogin?: string +): Promise { + try { + return await fetchFormattedActivity(token); + } catch { + if (!githubLogin) { + throw new Error("GitHub API error"); + } + + const events = await fetchPublicEvents(token, githubLogin); + + return events + .map(formatActivity) + .filter((item): item is ActivityItem => item !== null) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + } +} + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session?.accessToken) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const accountId = req.nextUrl.searchParams.get("accountId"); + + if (!accountId) { + try { + const items = await fetchFormattedActivityWithFallback( + session.accessToken, + session.githubLogin + ); + return Response.json({ items: items.slice(0, 15) }); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + } + + if (!session.githubId || !session.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { data: userRow } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + if (!userRow) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (accountId === "combined") { + const accounts = await getAllAccounts( + { + token: session.accessToken, + githubId: session.githubId, + githubLogin: session.githubLogin, + }, + userRow.id + ); + + const results = await Promise.allSettled( + accounts.map((account) => + fetchFormattedActivityWithFallback(account.token, account.githubLogin) + ) + ); + + const merged = results + .filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled" + ) + .flatMap((result) => result.value) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + .slice(0, 15); + + if (merged.length === 0 && results.length > 0) { + const allFailed = results.every((result) => result.status === "rejected"); + if (allFailed) { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + } + + return Response.json({ items: merged }); + } + + if (accountId === session.githubId) { + try { + const items = await fetchFormattedActivityWithFallback( + session.accessToken, + session.githubLogin + ); + return Response.json({ items: items.slice(0, 15) }); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + } + + const token = await getAccountToken(userRow.id, accountId); + + if (!token) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + + const { data: accountRow } = await supabaseAdmin + .from("user_github_accounts") + .select("github_login") + .eq("user_id", userRow.id) + .eq("github_id", accountId) + .single(); + + if (!accountRow?.github_login) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + + try { + const items = await fetchFormattedActivityWithFallback( + token, + accountRow.github_login + ); + return Response.json({ items: items.slice(0, 15) }); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 23f9881..14b1863 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -17,6 +17,7 @@ import WeeklySummaryCard from "@/components/WeeklySummaryCard"; import ExportButton from "@/components/ExportButton"; import Link from "next/link"; import PersonalRecords from "@/components/PersonalRecords"; +import RecentActivity from "@/components/RecentActivity"; import { authOptions } from "@/lib/auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; @@ -91,6 +92,11 @@ export default async function DashboardPage() { + + {/* Row 6: Recent GitHub activity */} +
+ +
); } diff --git a/src/components/RecentActivity.tsx b/src/components/RecentActivity.tsx new file mode 100644 index 0000000..7a2504a --- /dev/null +++ b/src/components/RecentActivity.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { useAccount } from "@/components/AccountContext"; + +type ActivityType = + | "push" + | "pull_request" + | "issue" + | "release" + | "other"; + +interface ActivityItem { + id: string; + type: ActivityType; + createdAt: string; + title: string; + subtitle: string; + repo: string; + url: string; +} + +function getTypeBadge(type: ActivityType): string { + if (type === "push") return "Push"; + if (type === "pull_request") return "PR"; + if (type === "issue") return "Issue"; + if (type === "release") return "Release"; + return "Event"; +} + +function getTypeIcon(type: ActivityType): ReactNode { + if (type === "push") { + return ( + + ); + } + + if (type === "pull_request") { + return ( + + ); + } + + if (type === "issue") { + return ( + + ); + } + + if (type === "release") { + return ( + + ); + } + + return null; +} + +function formatEventTime(value: string): string { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "Unknown time"; + } + + const diffMs = Date.now() - date.getTime(); + const diffMinutes = Math.floor(diffMs / 60000); + + if (diffMinutes < 1) return "Just now"; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export default function RecentActivity() { + const { selectedAccount } = useAccount(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchActivity = useCallback(() => { + setLoading(true); + setError(null); + + const query = + selectedAccount !== null + ? `?accountId=${encodeURIComponent(selectedAccount)}` + : ""; + + fetch(`/api/metrics/activity${query}`) + .then((res) => { + if (!res.ok) { + throw new Error("API error"); + } + return res.json(); + }) + .then((payload: { items?: ActivityItem[] }) => + setItems(payload.items ?? []) + ) + .catch(() => + setError( + "We couldn't load your recent activity right now. Please try again in a moment." + ) + ) + .finally(() => setLoading(false)); + }, [selectedAccount]); + + useEffect(() => { + fetchActivity(); + }, [fetchActivity]); + + return ( +
+
+
+

+ Recent Activity +

+

+ Your latest GitHub events +

+
+ +
+ + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ) : error ? ( +
+

{error}

+ +
+ ) : items.length === 0 ? ( +

+ No recent GitHub activity yet. +

+ ) : ( + + )} +
+ ); +}