Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/app/api/metrics/discussions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";

export const dynamic = "force-dynamic";

const GITHUB_API = "https://api.github.com";

interface DiscussionData {
discussionsStarted: number;
commentsGiven: number;
markedAsAnswer: number;
}

async function fetchJson<T>(url: string, token: string): Promise<T> {
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
},
cache: "no-store",
});
if (!res.ok) throw new Error("GitHub API error");
return (await res.json()) as T;
}

async function fetchGraphQL<T>(
query: string,
token: string
): Promise<T> {
const res = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
cache: "no-store",
});
if (!res.ok) throw new Error("GitHub GraphQL error");
const json = await res.json();
return json.data as T;
}

export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.accessToken || !session.githubLogin) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const login = session.githubLogin;
const token = session.accessToken;

try {
// 1) Discussions started — REST search
const discussionsRes = await fetchJson<{ total_count: number }>(
`${GITHUB_API}/search/issues?q=author:${login}+type:discussion&per_page=1`,
token
);
const discussionsStarted =
typeof discussionsRes.total_count === "number"
? discussionsRes.total_count
: 0;

// 2) Discussion comments and marked-as-answer via GraphQL
const query = `
{
user(login: "${login}") {
contributionsCollection {
totalCommitContributions
}
}
search(query: "commenter:${login} type:discussion", type: DISCUSSION, first: 100) {
discussionCount
}
}
`;

let commentsGiven = 0;
let markedAsAnswer = 0;

try {
// GraphQL for marked-as-answer count
const answersQuery = `
{
search(query: "answered-by:${login} type:discussion", type: DISCUSSION, first: 1) {
discussionCount
}
}
`;
const answersData = await fetchGraphQL<{
search: { discussionCount: number };
}>(answersQuery, token);
markedAsAnswer = answersData?.search?.discussionCount ?? 0;

// GraphQL for comments given
const commentsData = await fetchGraphQL<{
search: { discussionCount: number };
}>(query, token);
commentsGiven = commentsData?.search?.discussionCount ?? 0;
} catch {
// GraphQL may not support discussion search — fallback to 0
}

const data: DiscussionData = {
discussionsStarted,
commentsGiven,
markedAsAnswer,
};

return Response.json(data, {
headers: {
// cache for 5 minutes to avoid rate limit issues
"Cache-Control": "private, max-age=300",
},
});
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
5 changes: 5 additions & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import DiscussionsWidget from "@/components/DiscussionsWidget";
import ContributionGraph from "@/components/ContributionGraph";
import ContributionHeatmap from "@/components/ContributionHeatmap";
import PRMetrics from "@/components/PRMetrics";
Expand Down Expand Up @@ -83,6 +84,10 @@ export default async function DashboardPage() {
</div>
<CIAnalytics />
</div>
{/* Row 3b: Discussion activity */}
<div className="mt-6">
<DiscussionsWidget />
</div>

{/* Row 4: Pinned repositories */}
<div className="mt-6">
Expand Down
101 changes: 101 additions & 0 deletions src/components/DiscussionsWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"use client";

import { useCallback, useEffect, useState } from "react";

interface DiscussionData {
discussionsStarted: number;
commentsGiven: number;
markedAsAnswer: number;
}

export default function DiscussionsWidget() {
const [data, setData] = useState<DiscussionData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const fetchData = useCallback(() => {
setLoading(true);
setError(null);
fetch("/api/metrics/discussions")
.then((r) => {
if (!r.ok) throw new Error("API error");
return r.json();
})
.then((d: DiscussionData) => setData(d))
.catch(() =>
setError("We couldn't load your discussion metrics right now.")
)
.finally(() => setLoading(false));
}, []);

useEffect(() => {
fetchData();
}, [fetchData]);

const stats = data
? [
{
label: "Discussions Started",
value: data.discussionsStarted,
title: "Total discussions you have opened",
},
{
label: "Comments Given",
value: data.commentsGiven,
title: "Discussions you have commented on",
},
{
label: "Marked as Answer",
value: data.markedAsAnswer,
title: "Your replies marked as the accepted answer",
},
]
: [];

return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-[var(--card-foreground)]">
Discussion Activity
</h2>

{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-20 rounded-lg bg-[var(--card-muted)] animate-pulse"
/>
))}
</div>
) : error ? (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-400">
<p>{error}</p>
<button
type="button"
onClick={fetchData}
className="mt-3 rounded-md border border-red-500/30 px-3 py-1.5 text-xs font-medium text-red-300 transition-colors hover:bg-red-500/10"
>
Try again
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{stats.map((stat) => (
<div
key={stat.label}
className="rounded-lg bg-[var(--control)] p-4 text-center"
title={stat.title}
>
<div className="text-2xl font-bold text-[var(--accent)]">
{stat.value}
</div>
<div className="mt-1 text-sm text-[var(--muted-foreground)]">
{stat.label}
</div>
</div>
))}
</div>
)}
</div>
);
}
Loading