From 36e948f72c6861aa4ace9c3f509946d513200a9e Mon Sep 17 00:00:00 2001 From: Arryan21-06 Date: Sat, 1 Aug 2026 02:39:57 +0530 Subject: [PATCH] fix: eliminate N+1 dashboard metrics API requests --- src/app/api/metrics/route.ts | 97 +++++++++++++++++++ src/app/dashboard/page.tsx | 13 ++- src/components/CommunityMetrics.tsx | 76 +++++---------- src/components/ConsistencyScoreWidget.tsx | 66 ++++--------- src/components/DiscussionsWidget.tsx | 79 ++++++--------- src/components/IssueMetrics.tsx | 87 +++++++---------- src/components/PersonalRecords.tsx | 65 ++++++------- src/components/StreakAtRiskBanner.tsx | 33 +++---- src/components/StreakTracker.tsx | 86 ++++------------ src/components/WeeklyProgressSummary.tsx | 29 +----- src/components/WeeklySummaryCard.tsx | 78 ++++----------- .../dashboard/CustomizableDashboard.tsx | 13 +-- .../dashboard/DashboardMetricsContext.tsx | 36 +++++++ .../dashboard/DashboardMetricsLoader.tsx | 32 ++++++ src/hooks/useMetrics.ts | 13 +-- src/types/dashboard-metrics.ts | 95 ++++++++++++++++++ 16 files changed, 472 insertions(+), 426 deletions(-) create mode 100644 src/app/api/metrics/route.ts create mode 100644 src/components/dashboard/DashboardMetricsContext.tsx create mode 100644 src/components/dashboard/DashboardMetricsLoader.tsx create mode 100644 src/types/dashboard-metrics.ts diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts new file mode 100644 index 000000000..dbf572072 --- /dev/null +++ b/src/app/api/metrics/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { DashboardMetricsData } from "@/types/dashboard-metrics"; + +const DEFAULT_CONTRIBUTION_DAYS = 30; +const STREAK_CONTRIBUTION_DAYS = 365; +const PR_RANGE = "30d"; + +function buildQuery(params: Record) { + return new URLSearchParams( + Object.entries(params).filter(([, value]) => value !== undefined) as [string, string][], + ).toString(); +} + +function buildInternalUrl(req: NextRequest, path: string, params: Record = {}) { + const url = new URL(path, req.url); + const query = buildQuery(params); + if (query) url.search = query; + return url; +} + +export async function GET(req: NextRequest) { + const accountId = req.nextUrl.searchParams.get("accountId") ?? undefined; + const timezone = req.nextUrl.searchParams.get("timezone") ?? undefined; + + const queryParams = { + accountId, + }; + + const contributionQuery = { + ...queryParams, + days: String(DEFAULT_CONTRIBUTION_DAYS), + timezone, + }; + + const contribution365Query = { + ...queryParams, + days: String(STREAK_CONTRIBUTION_DAYS), + timezone, + }; + + const prQuery = { + ...queryParams, + range: PR_RANGE, + }; + + const endpoints = [ + { + key: "weeklySummary", + url: buildInternalUrl(req, "/api/metrics/weekly-summary", queryParams), + }, + { + key: "streak", + url: buildInternalUrl(req, "/api/metrics/streak", queryParams), + }, + { + key: "contributions30", + url: buildInternalUrl(req, "/api/metrics/contributions", contributionQuery), + }, + { + key: "contributions365", + url: buildInternalUrl(req, "/api/metrics/contributions", contribution365Query), + }, + { + key: "prs", + url: buildInternalUrl(req, "/api/metrics/prs", prQuery), + }, + { + key: "consistencyScore", + url: buildInternalUrl(req, "/api/metrics/consistency-score", queryParams), + }, + { + key: "discussions", + url: buildInternalUrl(req, "/api/metrics/discussions", queryParams), + }, + { + key: "issues", + url: buildInternalUrl(req, "/api/metrics/issues", queryParams), + }, + ] as const; + + const responses = await Promise.all( + endpoints.map(async (endpoint) => { + const res = await fetch(endpoint.url.toString(), { cache: "no-store" }); + if (!res.ok) { + throw new Error(`Metric endpoint failed: ${endpoint.key}`); + } + return { key: endpoint.key, json: await res.json() }; + }), + ); + + const data = responses.reduce((acc, response) => { + acc[response.key] = response.json; + return acc; + }, {} as DashboardMetricsData); + + return NextResponse.json(data); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 24986fa52..f78dd6fb5 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -13,6 +13,7 @@ import { decode } from "next-auth/jwt"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import DashboardSSEProvider from "@/components/DashboardSSEProvider"; +import DashboardMetricsLoader from "@/components/dashboard/DashboardMetricsLoader"; import { DashboardWidgetA11yProvider } from "@/components/dashboard/DashboardWidgetA11yContext"; import RoastHypeWidget from "./RoastHypeWidget"; @@ -49,11 +50,12 @@ export default async function DashboardPage() { return ( - -
- + + +
+ -
+
{/* Quick actions */}
- +
+ ); } \ No newline at end of file diff --git a/src/components/CommunityMetrics.tsx b/src/components/CommunityMetrics.tsx index fdb4d8f34..6c39b4b14 100644 --- a/src/components/CommunityMetrics.tsx +++ b/src/components/CommunityMetrics.tsx @@ -1,8 +1,7 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { useAccount } from "@/components/AccountContext"; -import { toast } from "sonner"; +import { useMemo } from "react"; +import { useDashboardMetrics } from "@/components/dashboard/DashboardMetricsContext"; interface CommunityData { discussionsStarted: number; @@ -11,55 +10,26 @@ interface CommunityData { } export default function CommunityMetrics() { - const { selectedAccount } = useAccount(); - const [metrics, setMetrics] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { metrics, loading, error, refetch } = useDashboardMetrics(); + const data = metrics?.discussions ?? 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((err) => { - console.error("Failed to fetch community metrics:", err); - setError( - "We couldn't load your discussion analytics right now. Please try again in a moment." - ); - toast.error("Failed to load community metrics"); - }) - .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 stats = useMemo( + () => + data + ? [ + { label: "Discussions Started (30d)", value: data.discussionsStarted }, + { label: "Accepted Answers", value: data.acceptedAnswers }, + { label: "Discussion Comments", value: data.commentsPosted }, + ] + : [], + [data], + ); const isEmpty = - metrics != null && - metrics.discussionsStarted === 0 && - metrics.acceptedAnswers === 0 && - metrics.commentsPosted === 0; + data != null && + data.discussionsStarted === 0 && + data.acceptedAnswers === 0 && + data.commentsPosted === 0; return (
@@ -74,7 +44,7 @@ export default function CommunityMetrics() {
- ) : metrics ? ( + ) : data ? (
{stats.map((stat) => ( diff --git a/src/components/ConsistencyScoreWidget.tsx b/src/components/ConsistencyScoreWidget.tsx index 20f312218..decbe8c18 100644 --- a/src/components/ConsistencyScoreWidget.tsx +++ b/src/components/ConsistencyScoreWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useMemo } from "react"; import { Bar, BarChart, @@ -12,7 +12,7 @@ import { } from "recharts"; import { BarChart3 } from "lucide-react"; import SectionHeader from "@/components/SectionHeader"; -import { useAccount } from "@/components/AccountContext"; +import { useDashboardMetrics } from "@/components/dashboard/DashboardMetricsContext"; import { isRecentlyActiveFromScore, type ConsistencyScoreResult, @@ -101,49 +101,25 @@ function ConsistencyScoreSkeleton() { } export default function ConsistencyScoreWidget() { - const { selectedAccount } = useAccount(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchScore = useCallback(async () => { - setLoading(true); - setError(null); - - try { - const url = - selectedAccount !== null - ? `/api/metrics/consistency-score?accountId=${encodeURIComponent(selectedAccount)}` - : "/api/metrics/consistency-score"; - const res = await fetch(url); - - if (!res.ok) { - throw new Error("Failed to fetch consistency score"); - } - - const json = (await res.json()) as ConsistencyScoreResult; - setData(json); - } catch (err) { - console.error("Failed to fetch consistency score:", err); - setError("We couldn't load your consistency score right now. Please try again in a moment."); - } finally { - setLoading(false); - } - }, [selectedAccount]); - - useEffect(() => { - fetchScore(); - }, [fetchScore]); + const { metrics, loading, error, refetch } = useDashboardMetrics(); + const data = metrics?.consistencyScore ?? null; + + const stats = useMemo( + () => + data + ? [ + { label: "Weekly Consistency", value: `${data.weeklyConsistency}%` }, + { label: "Streak Quality", value: `${Math.round(data.streakQuality * 100)}%` }, + { label: "Longest Gap", value: `${data.longestGap} days` }, + { label: "Recent Activity", value: isRecentlyActiveFromScore(data) ? "Active" : "Inactive" }, + ] + : [], + [data], + ); - useEffect(() => { - const handleSync = () => { - fetchScore(); - }; - window.addEventListener("devtrack:sync", handleSync); - return () => window.removeEventListener("devtrack:sync", handleSync); - }, [fetchScore]); + const isLoading = loading && data === null; - if (loading) { + if (isLoading) { return ; } @@ -152,10 +128,10 @@ export default function ConsistencyScoreWidget() {
-

{error}

+

{error.message ?? String(error)}

) : error ? (
-

{error}

+

{error.message ?? String(error)}

diff --git a/src/components/WeeklySummaryCard.tsx b/src/components/WeeklySummaryCard.tsx index 7c0291253..c598ff3d9 100644 --- a/src/components/WeeklySummaryCard.tsx +++ b/src/components/WeeklySummaryCard.tsx @@ -1,8 +1,7 @@ "use client"; import { Check, ChevronDown, Copy, Download, Sparkles } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { useAccount } from "@/components/AccountContext"; +import { useCallback, useState } from "react"; import { signOut } from "next-auth/react"; import { SkeletonBlock } from "./WidgetSkeleton"; @@ -38,12 +37,11 @@ interface AiSummaryState { copied: boolean; } +import { useDashboardMetrics } from "@/components/dashboard/DashboardMetricsContext"; + export default function WeeklySummaryCard() { - const { selectedAccount } = useAccount(); - const [summary, setSummary] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [githubAuthInvalid, setGithubAuthInvalid] = useState(false); + const { metrics, loading: metricsLoading, error: metricsError, refetch } = useDashboardMetrics(); + const summary = metrics?.weeklySummary ?? null; const [isCollapsed, setIsCollapsed] = useState(false); const [ai, setAi] = useState({ @@ -79,41 +77,8 @@ export default function WeeklySummaryCard() { ? Math.max(issuesThisWeek, issuesLastWeek, 1) : 1; - const fetchSummary = useCallback(() => { - setLoading(true); - setError(null); - setGithubAuthInvalid(false); - - const url = - selectedAccount !== null - ? `/api/metrics/weekly-summary?accountId=${encodeURIComponent(selectedAccount)}` - : "/api/metrics/weekly-summary"; - - fetch(url) - .then(async (r) => { - const data = await r.json(); - if (data?.error === "token_expired") { - setGithubAuthInvalid(true); - return null; - } - if (!r.ok) throw new Error("API error"); - return data as WeeklySummaryData; - }) - .then((data) => { - if (!data) return; - setSummary(data); - }) - .catch(() => - setError( - "We couldn't load your weekly summary right now. Please try again in a moment." - ) - ) - .finally(() => setLoading(false)); - }, [selectedAccount]); - - useEffect(() => { - fetchSummary(); - }, [fetchSummary]); + const isLoading = metricsLoading; + const error = metricsError ? metricsError.message : null; const handleDownload = () => { if (!summary) return; @@ -240,7 +205,7 @@ ${ai.text ? `\nAI Summary\n----------\n${ai.text}` : ""} This Week
- {summary && !loading && !ai.text && !ai.loading && !rateLimitMessage && ( + {summary && !isLoading && !ai.text && !ai.loading && !rateLimitMessage && (
- ) : error ? ( -
- {error} + ) : !summary ? ( +
+ No weekly summary available at the moment.
) : summary && summary.commits && diff --git a/src/components/dashboard/CustomizableDashboard.tsx b/src/components/dashboard/CustomizableDashboard.tsx index 6ab309446..afdf540a0 100644 --- a/src/components/dashboard/CustomizableDashboard.tsx +++ b/src/components/dashboard/CustomizableDashboard.tsx @@ -42,7 +42,6 @@ import RecentActivity from "@/components/RecentActivity"; import DailyNoteWidget from "@/components/DailyNoteWidget"; import WidgetErrorBoundary from "@/components/WidgetErrorBoundary"; import DashboardLayoutToolbar from "@/components/dashboard/DashboardLayoutToolbar"; -import { DashboardWidgetA11yProvider } from "@/components/dashboard/DashboardWidgetA11yContext"; import ConfirmModal from "@/components/ConfirmModal"; import { toast } from "sonner"; import SortableDashboardWidget from "@/components/dashboard/SortableDashboardWidget"; @@ -676,12 +675,11 @@ export default function CustomizableDashboard() { : "Layout editing disabled."}

- - + {layout.sections.map((sectionId) => { const sectionWidgets = layout.widgets[sectionId]; @@ -732,7 +730,6 @@ export default function CustomizableDashboard() { ); })} - Promise; +} + +const DashboardMetricsContext = createContext({ + metrics: null, + loading: true, + error: null, + refetch: async () => {}, +}); + +export function DashboardMetricsProvider({ + children, + value, +}: { + children: ReactNode; + value: DashboardMetricsContextValue; +}) { + return ( + + {children} + + ); +} + +export function useDashboardMetrics() { + return useContext(DashboardMetricsContext); +} diff --git a/src/components/dashboard/DashboardMetricsLoader.tsx b/src/components/dashboard/DashboardMetricsLoader.tsx new file mode 100644 index 000000000..e2d911667 --- /dev/null +++ b/src/components/dashboard/DashboardMetricsLoader.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { type ReactNode, useMemo } from "react"; +import { useAccount } from "@/components/AccountContext"; +import { DashboardMetricsProvider } from "./DashboardMetricsContext"; +import { useMetrics } from "@/hooks/useMetrics"; + +export default function DashboardMetricsLoader({ children }: { children: ReactNode }) { + const { selectedAccount } = useAccount(); + const timezone = useMemo( + () => Intl.DateTimeFormat().resolvedOptions().timeZone, + [], + ); + const metricsParams = useMemo( + () => ({ accountId: selectedAccount ?? undefined, timezone }), + [selectedAccount, timezone], + ); + const { data, loading, error, refetch } = useMetrics(metricsParams); + + return ( + + {children} + + ); +} diff --git a/src/hooks/useMetrics.ts b/src/hooks/useMetrics.ts index 9fde9d739..49f9da7cb 100644 --- a/src/hooks/useMetrics.ts +++ b/src/hooks/useMetrics.ts @@ -22,8 +22,8 @@ function buildUrl(url: string, params?: Record) { return qs ? `${url}?${qs}` : url; } -export function useMetrics(): UseMetricsResult { - const [data, setData] = useState(null); +export function useMetrics(params?: Record): UseMetricsResult { + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -32,24 +32,21 @@ export function useMetrics(): UseMetricsResult { setError(null); try { - // NOTE: This hook intentionally targets the base `/api/metrics` endpoint. - // Specific widgets may have their own endpoints; those should remain - // component-specific unless explicitly migrated. - const url = buildUrl("/api/metrics"); + const url = buildUrl("/api/metrics", params); const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to fetch metrics (${res.status})`); } - const json = (await res.json()) as Metrics; + const json = (await res.json()) as TData; setData(json); } catch (e) { setError(e instanceof Error ? e : new Error("Failed to fetch metrics")); } finally { setLoading(false); } - }, []); + }, [params]); useEffect(() => { void refetch(); diff --git a/src/types/dashboard-metrics.ts b/src/types/dashboard-metrics.ts new file mode 100644 index 000000000..2b61963db --- /dev/null +++ b/src/types/dashboard-metrics.ts @@ -0,0 +1,95 @@ +import type { ConsistencyScoreResult } from "@/lib/consistency-score"; + +export interface WeeklySummaryData { + commits: { + current: number; + previous: number; + delta: number; + trend: "up" | "down" | "same"; + }; + prs: { + thisWeek: { opened: number; merged: number }; + lastWeek: { opened: number; merged: number }; + }; + issues?: { + thisWeek: { opened: number; closed: number } | number; + lastWeek: { opened: number; closed: number } | number; + }; + productivityScore?: { + current: number; + previous: number; + }; + activeDays: { + thisWeek: number; + lastWeek: number; + }; + streak: number; + topRepo: string | null; + repoBreakdown?: { repoName: string; commits: number }[]; + dailyCommits?: { date: string; commits: number }[]; + mostActiveDay?: string | null; +} + +export interface StreakData { + current: number; + longest: number; + lastCommitDate: string | null; + totalActiveDays: number; + freezeDates: string[]; +} + +export interface ContributionData { + days: number; + total: number; + data: Record; +} + +export interface PRMetricsSummary { + open: number; + merged: number; + closed: number; + total: number; + totalAdditions?: number; + totalDeletions?: number; + avgReviewHours: number; + avgFirstReviewHours: number | null; + mergeRate: string; + avgCycleTime?: number; + weeklyTrend?: { week: string; avgHours: number }[]; + slowestRepos?: { repo: string; avgHours: number }[]; +} + +export interface PRData extends PRMetricsSummary { + gitlab?: PRMetricsSummary; + reviews?: { + totalReviews: number; + approvalRate: string; + topRepos: { repo: string; count: number }[]; + }; +} + +export interface CommunityData { + discussionsStarted: number; + acceptedAnswers: number; + commentsPosted: number; +} + +export interface IssueData { + opened: number; + closed: number; + currentlyOpen: number; + avgCloseTimeDays: number; + trend: number; + mostActiveRepo: string | null; +} + +export interface DashboardMetricsData { + weeklySummary?: WeeklySummaryData; + streak?: StreakData; + contributions30?: ContributionData; + contributions365?: ContributionData; + prs?: PRData; + consistencyScore?: ConsistencyScoreResult; + discussions?: CommunityData; + issues?: IssueData; +}