From ec763aaeebb53dc2a48dfecb6574ff01b24349c3 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 28 Jul 2026 13:17:47 +0530 Subject: [PATCH 1/3] feat(metrics): add GitHub Discussions metrics route and DiscussionsMetrics component --- src/app/api/metrics/discussions/route.ts | 118 +++++++++-- src/components/DiscussionsMetrics.tsx | 252 +++++++++++++++++++++++ src/components/DiscussionsWidget.tsx | 125 +---------- 3 files changed, 360 insertions(+), 135 deletions(-) create mode 100644 src/components/DiscussionsMetrics.tsx diff --git a/src/app/api/metrics/discussions/route.ts b/src/app/api/metrics/discussions/route.ts index b2904626f..1c62744ac 100644 --- a/src/app/api/metrics/discussions/route.ts +++ b/src/app/api/metrics/discussions/route.ts @@ -17,10 +17,19 @@ import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; -interface DiscussionsMetrics { +export interface CategoryCount { + category: string; + count: number; +} + +export interface DiscussionsMetrics { discussionsStarted: number; + commentsLeft: number; + commentsGiven: number; acceptedAnswers: number; - commentsPosted: number; + markedAsAnswer: number; + answeredRate: number; + topCategories: CategoryCount[]; } const DISCUSSIONS_QUERY = ` @@ -31,6 +40,14 @@ const DISCUSSIONS_QUERY = ` totalDiscussionCommentContributions totalDiscussionAnswerContributions } + repositoryDiscussions(first: 100, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + createdAt + category { + name + } + } + } } } `; @@ -57,9 +74,17 @@ async function fetchDiscussionsMetrics( async () => { if (token === "mock-token") { return { - discussionsStarted: 5, - acceptedAnswers: 2, - commentsPosted: 14, + discussionsStarted: 12, + commentsLeft: 28, + commentsGiven: 28, + acceptedAnswers: 5, + markedAsAnswer: 5, + answeredRate: 41.7, + topCategories: [ + { category: "Q&A", count: 6 }, + { category: "Ideas", count: 4 }, + { category: "General", count: 2 }, + ], }; } const { from, to } = getWindowDates(days); @@ -89,16 +114,53 @@ async function fetchDiscussionsMetrics( totalDiscussionCommentContributions?: number | null; totalDiscussionAnswerContributions?: number | null; } | null; + repositoryDiscussions?: { + nodes?: Array<{ + createdAt?: string | null; + category?: { + name?: string | null; + } | null; + } | null> | null; + } | null; } | null; }; }; const collection = data.data?.viewer?.contributionsCollection; + const discussionsStarted = collection?.totalDiscussionContributions ?? 0; + const acceptedAnswers = collection?.totalDiscussionAnswerContributions ?? 0; + const commentsLeft = collection?.totalDiscussionCommentContributions ?? 0; + + const answeredRate = + discussionsStarted > 0 + ? Math.round((acceptedAnswers / discussionsStarted) * 1000) / 10 + : 0; + + const categoryMap: Record = {}; + const nodes = data.data?.viewer?.repositoryDiscussions?.nodes ?? []; + const fromDate = new Date(from); + + for (const node of nodes) { + if (!node) continue; + const createdAt = node.createdAt ? new Date(node.createdAt) : null; + if (!createdAt || createdAt < fromDate) continue; + const catName = node.category?.name || "General"; + categoryMap[catName] = (categoryMap[catName] || 0) + 1; + } + + const topCategories: CategoryCount[] = Object.entries(categoryMap) + .map(([category, count]) => ({ category, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 3); return { - discussionsStarted: collection?.totalDiscussionContributions ?? 0, - acceptedAnswers: collection?.totalDiscussionAnswerContributions ?? 0, - commentsPosted: collection?.totalDiscussionCommentContributions ?? 0, + discussionsStarted, + commentsLeft, + commentsGiven: commentsLeft, + acceptedAnswers, + markedAsAnswer: acceptedAnswers, + answeredRate, + topCategories, }; } ); @@ -108,10 +170,31 @@ function mergeDiscussionMetrics( a: DiscussionsMetrics, b: DiscussionsMetrics ): DiscussionsMetrics { + const discussionsStarted = a.discussionsStarted + b.discussionsStarted; + const acceptedAnswers = a.acceptedAnswers + b.acceptedAnswers; + const commentsLeft = a.commentsLeft + b.commentsLeft; + const answeredRate = + discussionsStarted > 0 + ? Math.round((acceptedAnswers / discussionsStarted) * 1000) / 10 + : 0; + + const categoryMap: Record = {}; + for (const item of [...(a.topCategories || []), ...(b.topCategories || [])]) { + categoryMap[item.category] = (categoryMap[item.category] || 0) + item.count; + } + const topCategories = Object.entries(categoryMap) + .map(([category, count]) => ({ category, count })) + .sort((x, y) => y.count - x.count) + .slice(0, 3); + return { - discussionsStarted: a.discussionsStarted + b.discussionsStarted, - acceptedAnswers: a.acceptedAnswers + b.acceptedAnswers, - commentsPosted: a.commentsPosted + b.commentsPosted, + discussionsStarted, + commentsLeft, + commentsGiven: commentsLeft, + acceptedAnswers, + markedAsAnswer: acceptedAnswers, + answeredRate, + topCategories, }; } @@ -130,7 +213,18 @@ export async function GET(req: NextRequest) { const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); - const days = 30; + + const daysParam = + req.nextUrl.searchParams.get("days") || + req.nextUrl.searchParams.get("range") || + req.nextUrl.searchParams.get("timeRange"); + let days = 30; + if (daysParam) { + const parsed = parseInt(daysParam.replace(/d$/, ""), 10); + if (!isNaN(parsed) && [7, 30, 90].includes(parsed)) { + days = parsed; + } + } if (!accountId) { try { diff --git a/src/components/DiscussionsMetrics.tsx b/src/components/DiscussionsMetrics.tsx new file mode 100644 index 000000000..2511c58d9 --- /dev/null +++ b/src/components/DiscussionsMetrics.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { useAccount } from "@/components/AccountContext"; +import { SkeletonBlock } from "./WidgetSkeleton"; + +export interface CategoryCount { + category: string; + count: number; +} + +export interface DiscussionsMetricsData { + discussionsStarted: number; + commentsLeft: number; + commentsGiven?: number; + acceptedAnswers: number; + markedAsAnswer?: number; + answeredRate: number; + topCategories: CategoryCount[]; +} + +export default function DiscussionsMetrics() { + const { selectedAccount } = useAccount(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [days, setDays] = useState(30); + + const fetchData = useCallback(() => { + setLoading(true); + setError(null); + + const params = new URLSearchParams(); + params.set("days", days.toString()); + if (selectedAccount !== null) { + params.set("accountId", selectedAccount); + } + + fetch(`/api/metrics/discussions?${params.toString()}`) + .then((r) => { + if (!r.ok) throw new Error("API error"); + return r.json(); + }) + .then((d: DiscussionsMetricsData) => setData(d)) + .catch(() => + setError("We couldn't load your discussion metrics right now.") + ) + .finally(() => setLoading(false)); + }, [selectedAccount, days]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const commentsCount = data ? (data.commentsLeft ?? data.commentsGiven ?? 0) : 0; + const answeredCount = data ? (data.acceptedAnswers ?? data.markedAsAnswer ?? 0) : 0; + + const stats = data + ? [ + { + label: "Discussions Started", + value: data.discussionsStarted, + title: "Total discussions you have opened", + }, + { + label: "Comments Left", + value: commentsCount, + title: "Discussions comments you have posted", + }, + { + label: "Answered Rate", + value: `${data.answeredRate ?? 0}%`, + subtext: `${answeredCount} answered`, + title: "Percentage of discussions marked as answered", + }, + ] + : []; + + const hasNoDiscussionData = + !!data && + data.discussionsStarted === 0 && + commentsCount === 0 && + answeredCount === 0; + + const chartData = data?.topCategories && data.topCategories.length > 0 + ? data.topCategories.map((c) => ({ + category: c.category, + count: c.count, + })) + : []; + + return ( +
+
+

+ Discussion Activity +

+
+ {[7, 30, 90].map((d) => ( + + ))} +
+
+ + {loading ? ( +
+ Loading discussion metrics +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ +
+ ) : error ? ( +
+

{error}

+ +
+ ) : hasNoDiscussionData ? ( +
+
💬
+

+ No discussion activity yet +

+

+ Participate in GitHub Discussions during the last {days} days to see your activity and category breakdown here. +

+ + Explore Discussions + +
+ ) : ( +
+
+ {stats.map((stat) => ( +
+
+ {stat.value} +
+
+ {stat.label} +
+ {stat.subtext && ( +
+ {stat.subtext} +
+ )} +
+ ))} +
+ +
+

+ Top Categories +

+ {chartData.length === 0 ? ( +
+ No discussion category data for this time period. +
+ ) : ( +
+ + + + + + + + + +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/DiscussionsWidget.tsx b/src/components/DiscussionsWidget.tsx index ba8be0d61..f64db926f 100644 --- a/src/components/DiscussionsWidget.tsx +++ b/src/components/DiscussionsWidget.tsx @@ -1,128 +1,7 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; - -interface DiscussionData { - discussionsStarted: number; - commentsGiven: number; - markedAsAnswer: number; -} +import DiscussionsMetrics from "./DiscussionsMetrics"; export default function DiscussionsWidget() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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", - }, - ] - : []; - - const hasNoDiscussionData = - !!data && - data.discussionsStarted === 0 && - data.commentsGiven === 0 && - data.markedAsAnswer === 0; - - return ( -
-

- Discussion Activity -

- - {loading ? ( -
- {[1, 2, 3].map((i) => ( -
- ))} -
- ) : error ? ( -
-

{error}

- -
- ) : hasNoDiscussionData ? ( -
-
💬
- -

- No discussion activity yet -

- -

- Participate in GitHub Discussions to see your activity metrics here. -

- - - Explore Discussions - -
- ) : ( -
- {stats.map((stat) => ( -
-
- {stat.value} -
-
- {stat.label} -
-
- ))} -
- )} -
- ); + return ; } From dbedc4f1991b33395ac1f046abe6bb2b6794a107 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 28 Jul 2026 13:30:36 +0530 Subject: [PATCH 2/3] fix(workflow): update pr-star-required to use STAR_TOKEN secret fallback --- .github/workflows/pr-star-required.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-star-required.yml b/.github/workflows/pr-star-required.yml index 53ce21220..06bb0ce1a 100644 --- a/.github/workflows/pr-star-required.yml +++ b/.github/workflows/pr-star-required.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/github-script@v9 with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.STAR_TOKEN || secrets.GITHUB_TOKEN }} script: | const { owner, repo } = context.repo; const CONTEXT = 'star-required'; From 0d5062de8a1aa863620b8d85cf51a0abb4012eb3 Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 28 Jul 2026 13:39:06 +0530 Subject: [PATCH 3/3] test(streak): add comprehensive unit tests for commit streak calculator --- src/lib/__tests__/streak.test.ts | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/lib/__tests__/streak.test.ts diff --git a/src/lib/__tests__/streak.test.ts b/src/lib/__tests__/streak.test.ts new file mode 100644 index 000000000..7164736f1 --- /dev/null +++ b/src/lib/__tests__/streak.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + calculateStreakFromDates, + calculateStreak, + calculateCurrentStreak, +} from "@/lib/streak"; + +describe("Commit Streak Calculator Unit Tests (src/lib/streak.ts)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-15T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("5 consecutive days → streak = 5", () => { + const activeDates = new Set([ + "2026-06-11", + "2026-06-12", + "2026-06-13", + "2026-06-14", + "2026-06-15", + ]); + const result = calculateStreakFromDates(activeDates); + expect(result.current).toBe(5); + expect(result.longest).toBe(5); + expect(result.totalActiveDays).toBe(5); + }); + + it("Gap of 1 day with a freeze token → streak continues", () => { + const activeDates = new Set(["2026-06-13", "2026-06-15"]); + const freezeDates = new Set(["2026-06-14"]); + const result = calculateStreakFromDates(activeDates, freezeDates); + expect(result.current).toBe(3); + expect(result.longest).toBe(3); + expect(result.freezeDates).toContain("2026-06-14"); + }); + + it("Gap of 2 days with only 1 freeze → streak broken", () => { + // Gap on 06-13 and 06-14, but freeze only on 06-13 (06-14 is missing) + const activeDates = new Set(["2026-06-12", "2026-06-15"]); + const freezeDates = new Set(["2026-06-13"]); + const result = calculateStreakFromDates(activeDates, freezeDates); + // 06-12 to 06-13 = run length 2; 06-15 = run length 1 (ending today) + expect(result.current).toBe(1); + expect(result.longest).toBe(2); + }); + + it("Commits at 23:59 and 00:01 on adjacent days → counted as 2 days", () => { + const commit1 = new Date("2026-06-14T23:59:00Z"); + const commit2 = new Date("2026-06-15T00:01:00Z"); + const result = calculateStreak([commit1, commit2]); + expect(result.currentStreak).toBe(2); + expect(result.longestStreak).toBe(2); + }); + + it("UTC commit at 23:30 = IST commit at 05:00 next day → timezone edge case", () => { + // In UTC, this commit is on 2026-06-14; in IST (Asia/Kolkata UTC+5:30), it's 2026-06-15 05:00. + const activeDates = new Set(["2026-06-14", "2026-06-15"]); + const utcResult = calculateStreakFromDates(activeDates, new Set(), "UTC"); + expect(utcResult.current).toBe(2); + + const istResult = calculateStreakFromDates(activeDates, new Set(), "Asia/Kolkata"); + expect(istResult.current).toBe(2); + }); + + it("Multiple commits on same day → counts as 1 streak day", () => { + const commit1 = new Date("2026-06-15T08:00:00Z"); + const commit2 = new Date("2026-06-15T14:30:00Z"); + const commit3 = new Date("2026-06-15T22:45:00Z"); + const result = calculateStreak([commit1, commit2, commit3]); + expect(result.currentStreak).toBe(1); + expect(result.longestStreak).toBe(1); + }); + + it("No commits today but committed yesterday → streak still active (day not over)", () => { + // Current time is 2026-06-15T12:00:00Z (today). Active date is only 2026-06-14 (yesterday). + const activeDates = new Set(["2026-06-14"]); + const result = calculateStreakFromDates(activeDates); + expect(result.current).toBe(1); + expect(result.lastCommitDate).toBe("2026-06-14"); + }); +});