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
191 changes: 191 additions & 0 deletions src/app/api/metrics/discussions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import {
getAccountToken,
getAllAccounts,
mergeMetrics,
} from "@/lib/github-accounts";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
metricsCacheKey,
withMetricsCache,
} from "@/lib/metrics-cache";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";

interface DiscussionsMetrics {
discussionsStarted: number;
acceptedAnswers: number;
commentsPosted: number;
}

const DISCUSSIONS_QUERY = `
query DiscussionsMetrics($from: DateTime!, $to: DateTime!) {
viewer {
contributionsCollection(from: $from, to: $to) {
totalDiscussionContributions
totalDiscussionCommentContributions
totalDiscussionAnswerContributions
}
}
}
`;

function getWindowDates(days: number) {
const to = new Date();
const from = new Date(to.getTime() - days * 24 * 60 * 60 * 1000);
return { from: from.toISOString(), to: to.toISOString() };
}

async function fetchDiscussionsMetrics(
token: string,
days: number,
cacheContext: { bypass: boolean; userId: string }
): Promise<DiscussionsMetrics> {
const key = metricsCacheKey(cacheContext.userId, "discussions", { days });

return withMetricsCache(
{
bypass: cacheContext.bypass,
key,
ttlSeconds: METRICS_CACHE_TTL_SECONDS.discussions,
},
async () => {
const { from, to } = getWindowDates(days);
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: DISCUSSIONS_QUERY,
variables: { from, to },
}),
cache: "no-store",
});

if (!response.ok) {
throw new Error("GitHub API error");
}

const data = (await response.json()) as {
data?: {
viewer?: {
contributionsCollection?: {
totalDiscussionContributions?: number | null;
totalDiscussionCommentContributions?: number | null;
totalDiscussionAnswerContributions?: number | null;
} | null;
} | null;
};
};

const collection = data.data?.viewer?.contributionsCollection;

return {
discussionsStarted: collection?.totalDiscussionContributions ?? 0,
acceptedAnswers: collection?.totalDiscussionAnswerContributions ?? 0,
commentsPosted: collection?.totalDiscussionCommentContributions ?? 0,
};
}
);
}

function mergeDiscussionMetrics(
a: DiscussionsMetrics,
b: DiscussionsMetrics
): DiscussionsMetrics {
return {
discussionsStarted: a.discussionsStarted + b.discussionsStarted,
acceptedAnswers: a.acceptedAnswers + b.acceptedAnswers,
commentsPosted: a.commentsPosted + b.commentsPosted,
};
}

function formatDiscussionsMetrics(metrics: DiscussionsMetrics) {
return metrics;
}

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");
const bypass = isMetricsCacheBypassed(req);
const days = 30;

if (!accountId) {
try {
const result = await fetchDiscussionsMetrics(session.accessToken, days, {
bypass,
userId: session.githubId ?? session.githubLogin ?? "primary",
});
return Response.json(formatDiscussionsMetrics(result));
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}

if (!session.githubId || !session.githubLogin) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const userRow = await resolveAppUser(session.githubId, session.githubLogin);

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) =>
fetchDiscussionsMetrics(account.token, days, {
bypass,
userId: account.githubId,
})
)
);

const merged = mergeMetrics(results, mergeDiscussionMetrics);

if (!merged) {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}

return Response.json(formatDiscussionsMetrics(merged));
}

const token =
accountId === session.githubId
? session.accessToken
: await getAccountToken(userRow.id, accountId);

if (!token) {
return Response.json({ error: "Account not found" }, { status: 404 });
}

try {
const result = await fetchDiscussionsMetrics(token, days, {
bypass,
userId: accountId === session.githubId ? session.githubId : accountId,
});
return Response.json(formatDiscussionsMetrics(result));
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
6 changes: 4 additions & 2 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import ContributionGraph from "@/components/ContributionGraph";
import ContributionHeatmap from "@/components/ContributionHeatmap";
import PRMetrics from "@/components/PRMetrics";
import CommunityMetrics from "@/components/CommunityMetrics";
import PRBreakdownChart from "@/components/PRBreakdownChart";
import GoalTracker from "@/components/GoalTracker";
import DashboardHeader from "@/components/DashboardHeader";
Expand Down Expand Up @@ -65,9 +66,10 @@ export default async function DashboardPage() {
</div>
</div>

{/* Row 2: PR metrics, PR breakdown & Time Chart */}
<div className="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Row 2: PR metrics, community metrics, PR breakdown & Time Chart */}
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
<PRMetrics />
<CommunityMetrics />
<PRBreakdownChart />
<CommitTimeChart />
</div>
Expand Down
137 changes: 137 additions & 0 deletions src/components/CommunityMetrics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { useAccount } from "@/components/AccountContext";

interface CommunityData {
discussionsStarted: number;
acceptedAnswers: number;
commentsPosted: number;
}

export default function CommunityMetrics() {
const { selectedAccount } = useAccount();
const [metrics, setMetrics] = useState<CommunityData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const fetchMetrics = useCallback(() => {
setLoading(true);
setError(null);

const url =
selectedAccount !== null
? `/api/metrics/discussions?accountId=${encodeURIComponent(selectedAccount)}`
: "/api/metrics/discussions";

fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error("API error");
}
return response.json();
})
.then((data: CommunityData) => setMetrics(data))
.catch(() =>
setError(
"We couldn't load your discussion analytics right now. Please try again in a moment."
)
)
.finally(() => setLoading(false));
}, [selectedAccount]);

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

const stats = metrics
? [
{ label: "Discussions Started (30d)", value: metrics.discussionsStarted },
{ label: "Accepted Answers", value: metrics.acceptedAnswers },
{ label: "Discussion Comments", value: metrics.commentsPosted },
]
: [];

const isEmpty =
metrics != null &&
metrics.discussionsStarted === 0 &&
metrics.acceptedAnswers === 0 &&
metrics.commentsPosted === 0;

return (
<div className="h-full rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 sm:p-6 shadow-sm">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="text-lg font-semibold text-[var(--card-foreground)]">
Community Discussions
</h2>
<p className="mt-1 max-w-md text-sm text-[var(--muted-foreground)]">
GitHub Discussions activity across the selected account
</p>
</div>
<button
type="button"
onClick={fetchMetrics}
className="w-full rounded-md border border-[var(--border)] px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] transition-colors hover:bg-[var(--control)] sm:w-auto"
>
Refresh
</button>
</div>

{loading ? (
<div
role="status"
aria-live="polite"
aria-busy="true"
className="space-y-4"
>
<span className="sr-only">Loading discussion analytics</span>
<div className="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(10rem,1fr))]">
{[1, 2, 3].map((item) => (
<div
key={item}
aria-hidden="true"
className="min-h-20 rounded-lg bg-[var(--card-muted)] animate-pulse"
/>
))}
</div>
</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={fetchMetrics}
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>
) : metrics ? (
<div className="space-y-4">
<div className="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(10rem,1fr))]">
{stats.map((stat) => (
<div
key={stat.label}
className="rounded-lg bg-[var(--control)] p-4 text-center min-w-0 sm:min-h-24"
>
<div className="text-[clamp(1.5rem,5vw,1.75rem)] font-bold leading-none text-[var(--accent)]">
{stat.value}
</div>
<div className="mt-2 text-[clamp(0.75rem,2.4vw,0.875rem)] leading-snug text-[var(--muted-foreground)] break-words hyphens-auto">
{stat.label}
</div>
</div>
))}
</div>

{isEmpty && (
<div className="rounded-lg border border-[var(--border)] bg-[var(--control)]/60 p-4 text-sm text-[var(--muted-foreground)]">
No discussion activity yet in this 30-day window.
</div>
)}
</div>
) : null}
</div>
);
}
2 changes: 1 addition & 1 deletion src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const authOptions: NextAuthOptions = {
clientId: process.env.GITHUB_ID ?? "",
clientSecret: process.env.GITHUB_SECRET ?? "",
authorization: {
params: { scope: "read:user user:email repo" },
params: { scope: "read:user user:email repo read:discussion" },
},
}),
],
Expand Down
1 change: 1 addition & 0 deletions src/lib/metrics-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { NextRequest } from "next/server";

export const METRICS_CACHE_TTL_SECONDS = {
contributions: 5 * 60,
discussions: 10 * 60,
repos: 10 * 60,
prs: 10 * 60,
"pr-review-time": 10 * 60,
Expand Down
Loading