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
97 changes: 97 additions & 0 deletions src/app/api/metrics/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>) {
return new URLSearchParams(
Object.entries(params).filter(([, value]) => value !== undefined) as [string, string][],
).toString();
}

function buildInternalUrl(req: NextRequest, path: string, params: Record<string, string | undefined> = {}) {
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);
}
13 changes: 8 additions & 5 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -49,11 +50,12 @@ export default async function DashboardPage() {

return (
<DashboardSSEProvider>
<DashboardWidgetA11yProvider>
<main className="min-h-screen bg-[var(--background)] px-4 py-8 text-[var(--foreground)] transition-colors sm:px-6 lg:px-8 max-w-[1600px] mx-auto">
<DashboardHeader />
<DashboardMetricsLoader>
<DashboardWidgetA11yProvider>
<main className="min-h-screen bg-[var(--background)] px-4 py-8 text-[var(--foreground)] transition-colors sm:px-6 lg:px-8 max-w-[1600px] mx-auto">
<DashboardHeader />

<div className="mt-6 space-y-8">
<div className="mt-6 space-y-8">
{/* Quick actions */}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<Link
Expand Down Expand Up @@ -148,6 +150,7 @@ export default async function DashboardPage() {
</div>
</main>
</DashboardWidgetA11yProvider>
</DashboardSSEProvider>
</DashboardMetricsLoader>
</DashboardSSEProvider>
);
}
76 changes: 23 additions & 53 deletions src/components/CommunityMetrics.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,55 +10,26 @@ interface CommunityData {
}

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 { 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 (
<div className="h-full rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 sm:p-6 shadow-sm transition-all duration-300 hover:shadow-md hover:-translate-y-1">
Expand All @@ -74,7 +44,7 @@ export default function CommunityMetrics() {
</div>
<button
type="button"
onClick={fetchMetrics}
onClick={() => void refetch()}
disabled={loading}
aria-label="Refresh discussion analytics"
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-[var(--border)] px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] transition-all hover:bg-[var(--control)] sm:w-auto disabled:cursor-not-allowed disabled:opacity-60 hover:opacity-90 active:scale-95"
Expand Down Expand Up @@ -106,16 +76,16 @@ export default function CommunityMetrics() {
</div>
) : error ? (
<div className="rounded-lg border border-[var(--destructive)]/20 bg-[var(--destructive)]/10 p-4 text-sm text-[var(--destructive)]">
<p>{error}</p>
<p>{error.message ?? String(error)}</p>
<button
type="button"
onClick={fetchMetrics}
onClick={() => void refetch()}
className="mt-3 rounded-md border border-[var(--border)]/30 px-3 py-1.5 text-xs font-medium text-[var(--destructive)]/90 transition-colors hover:bg-[var(--destructive)]/10"
>
Try again
</button>
</div>
) : metrics ? (
) : data ? (
<div className="space-y-4">
<div className="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(10rem,1fr))]">
{stats.map((stat) => (
Expand Down
66 changes: 21 additions & 45 deletions src/components/ConsistencyScoreWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { useMemo } from "react";
import {
Bar,
BarChart,
Expand All @@ -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,
Expand Down Expand Up @@ -101,49 +101,25 @@ function ConsistencyScoreSkeleton() {
}

export default function ConsistencyScoreWidget() {
const { selectedAccount } = useAccount();
const [data, setData] = useState<ConsistencyScoreResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <ConsistencyScoreSkeleton />;
}

Expand All @@ -152,10 +128,10 @@ export default function ConsistencyScoreWidget() {
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<SectionHeader title="Consistency Score" />
<div className="rounded-lg border border-[var(--destructive)]/20 bg-[var(--destructive)]/10 p-4 text-sm text-[var(--destructive)]">
<p>{error}</p>
<p>{error.message ?? String(error)}</p>
<button
type="button"
onClick={fetchScore}
onClick={() => void refetch()}
className="mt-3 rounded-md border border-[var(--destructive)]/30 px-3 py-1.5 text-xs font-medium text-[var(--destructive)] transition-colors hover:bg-[var(--destructive)]/10"
>
Try again
Expand Down
Loading
Loading