diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx index 7438d2e4..0236f168 100644 --- a/src/app/analytics/page.tsx +++ b/src/app/analytics/page.tsx @@ -1,6 +1,8 @@ +/* eslint-disable react/no-unescaped-entities */ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; import { Card, CardContent, @@ -9,372 +11,252 @@ import { CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Separator } from "@/components/ui/separator"; -import analyticsData from "@/data/analytics.json"; -import Link from "next/link"; -type TimeRange = "7d" | "30d" | "90d" | "all"; +type AnalyticsPeriod = "7d" | "30d" | "90d"; + +type AnalyticsResponse = { + period: AnalyticsPeriod; + generatedAt: string; + summary: { + totalSkills: number; + totalAgents: number; + totalInstalls: number; + totalReviews: number; + }; + topSkills: Array<{ + slug: string; + name: string; + installs: number; + reviews: number; + averageRating: number; + }>; + activity: Array<{ + date: string; + count: number; + events: number; + bounties: number; + community: number; + }>; +}; + +const PERIOD_LABELS: Record = { + "7d": "Last 7 days", + "30d": "Last 30 days", + "90d": "Last 90 days", +}; + +const NUMBER_FORMATTER = new Intl.NumberFormat("en-US"); export default function AnalyticsPage() { - const [timeRange, setTimeRange] = useState("30d"); + const [period, setPeriod] = useState("30d"); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - // Get downloads data based on selected time range - const downloadsData = analyticsData.downloadsOverTime[timeRange]; - const maxDownloads = Math.max(...downloadsData.data); + useEffect(() => { + let cancelled = false; - // Calculate max value for skill chart scaling - const maxSkillDownloads = Math.max( - ...analyticsData.topSkills.map((s) => s.downloads) - ); + async function run() { + setLoading(true); + setError(null); + + try { + const response = await fetch(`/api/analytics?period=${period}`, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Request failed (${response.status})`); + } - // Category colors for pie-style display - const categoryColors = [ - "from-cyan to-cyan/80", - "from-purple to-purple/80", - "from-green to-green/80", - "from-yellow to-yellow/80", - "from-pink to-pink/80", - "from-blue to-blue/80", - "from-orange to-orange/80", - ]; + const payload = (await response.json()) as AnalyticsResponse; + if (!cancelled) { + setData(payload); + } + } catch (fetchError) { + if (!cancelled) { + setData(null); + setError(fetchError instanceof Error ? fetchError.message : "Failed to load analytics"); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + run().catch(() => { + if (!cancelled) { + setData(null); + setError("Failed to load analytics"); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [period]); + + const maxActivity = useMemo(() => { + if (!data?.activity?.length) return 1; + return Math.max(1, ...data.activity.map((entry) => entry.count)); + }, [data]); return (
- {/* Hero */} -
-
-
-
-
- -
-

- πŸ“Š Skill Analytics Dashboard -

+
+
+

πŸ“Š Analytics Dashboard

- Insights into skill performance, downloads, creators, and trends + Live metrics from skills, agents, installs, reviews, and community activity.

- {/* Time Range Selector */} -
-
- {(["7d", "30d", "90d", "all"] as TimeRange[]).map((range) => ( +
+
+ {(["7d", "30d", "90d"] as AnalyticsPeriod[]).map((candidate) => ( ))}
- {/* Overview Stats Cards */} -
-

πŸ“ˆ Overview

-
+ {loading ? ( +
- - - {analyticsData.overview.totalSkills.toLocaleString()} - - Total Skills - - -

- Published on platform -

-
+ Loading analytics…
+
+ ) : null} - - - - {analyticsData.overview.totalDownloads.toLocaleString()} - - Total Downloads - - -

- Across all skills -

+ {!loading && error ? ( +
+ + +

Couldn't load analytics

+

{error}

+
+ ) : null} - - - - {analyticsData.overview.activeCreators.toLocaleString()} - - Active Creators - - -

- Contributing skills -

-
-
+ {!loading && !error && data ? ( + <> +
+
+ + + + {NUMBER_FORMATTER.format(data.summary.totalSkills)} + + Total skills + + - - - - {analyticsData.overview.avgRating.toFixed(1)} ⭐ - - Average Rating - - -

- Across all skills -

-
-
-
-
+ + + + {NUMBER_FORMATTER.format(data.summary.totalAgents)} + + Total agents + + - + + + + {NUMBER_FORMATTER.format(data.summary.totalInstalls)} + + Total installs + + - {/* Downloads Over Time Chart */} -
-

- πŸ“‰ Downloads Over Time ({timeRange === "7d" ? "Last 7 Days" : timeRange === "30d" ? "Last 30 Days" : timeRange === "90d" ? "Last 90 Days" : "All Time"}) -

- - -
- {downloadsData.data.map((value, index) => { - const percentage = (value / maxDownloads) * 100; - return ( -
- - {downloadsData.labels[index]} - -
-
-
- - {value.toLocaleString()} - -
-
-
-
- ); - })} + + + + {NUMBER_FORMATTER.format(data.summary.totalReviews)} + + Total reviews + +
-
-
-
- - +
- {/* Top 10 Most Downloaded Skills */} -
-

πŸ† Top 10 Most Downloaded Skills

- - -
- {analyticsData.topSkills.map((skill, index) => { - const percentage = (skill.downloads / maxSkillDownloads) * 100; - return ( -
-
-
- +
+

πŸ† Top Skills by Installs

+ + + {data.topSkills.length === 0 ? ( +

No install data available yet.

+ ) : ( + data.topSkills.map((skill, index) => ( +
+
+ #{index + 1} -
- +
+ {skill.name} -
- - {skill.category} - - - {skill.rating} ⭐ - -
-
-
-
-

- {skill.downloads.toLocaleString()} -

-

downloads

-
-
-
-
-
-
- ); - })} -
-
-
-
- - - - {/* Category Breakdown and Creator Leaderboard */} -
-
- {/* Category Breakdown */} -
-

πŸ“¦ Category Breakdown

- - - {/* Pie-style visual */} -
-

- Skills by Category -

-
- {analyticsData.categoryBreakdown.map((cat, index) => ( -
- {cat.percentage > 10 && `${cat.percentage}%`} -
- ))} -
-
- - {/* Category list */} -
- {analyticsData.categoryBreakdown.map((cat, index) => ( -
-
-
-
-

- {cat.category} -

- {cat.count} skills · {cat.downloads.toLocaleString()}{" "} - downloads + {NUMBER_FORMATTER.format(skill.reviews)} reviews · {skill.averageRating.toFixed(1)} ⭐

- - {cat.percentage}% - +

+ {NUMBER_FORMATTER.format(skill.installs)} installs +

- ))} -
+ )) + )} -
+
- {/* Creator Leaderboard */} -
-

πŸ‘₯ Creator Leaderboard

+
+

πŸ“ˆ Activity Trend ({PERIOD_LABELS[data.period]})

- -
- {analyticsData.topCreators.map((creator, index) => ( -
-
- - {index + 1} - -
{creator.avatar}
-
- - {creator.displayName} - -

- {creator.skillsPublished} skills · {creator.avgRating}{" "} - ⭐ -

-
+ + {data.activity.map((entry) => { + const width = (entry.count / maxActivity) * 100; + return ( +
+
+ {entry.date} + + total {entry.count} Β· events {entry.events} Β· bounties {entry.bounties} Β· community {entry.community} +
-
-

- {creator.totalDownloads.toLocaleString()} -

-

downloads

+
+
- ))} -
+ ); + })} -
-
-
+
- - - {/* Footer Note */} -
- - -

- Analytics data reflects aggregated skill downloads and creator activity. All metrics are updated periodically. -

-

- Last updated: {new Date().toLocaleString()} +

+

+ Last updated: {new Date(data.generatedAt).toLocaleString()}

- - -
+
+ + ) : null}
); } diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts new file mode 100644 index 00000000..9bbada6a --- /dev/null +++ b/src/app/api/analytics/route.ts @@ -0,0 +1,197 @@ +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; +import { getAgents, getSkills } from "@/lib/data"; +import { getAllReviews } from "@/lib/reviews"; +import { getBounties } from "@/lib/bounties"; +import { readEventsFile } from "@/lib/eventsStore"; +import { readCommunityThreadsFile } from "@/lib/server/communityThreadsStore"; +import { readMcpInstallCounts } from "@/lib/server/mcpInstalls"; +import { readSkillMetricStore } from "@/lib/server/skillMetrics"; +import { readSkillInstallCounts } from "@/lib/server/skillInstalls"; + +export const runtime = "nodejs"; + +type Period = "7d" | "30d" | "90d"; + +type DayBucket = { + date: string; + count: number; + events: number; + bounties: number; + community: number; +}; + +function parsePeriod(value: string | null): Period { + if (value === "7d" || value === "30d" || value === "90d") { + return value; + } + return "30d"; +} + +function startOfUtcDay(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +function addUtcDays(date: Date, days: number): Date { + const next = new Date(date); + next.setUTCDate(next.getUTCDate() + days); + return next; +} + +function getRange(period: Period): { start: Date; end: Date; days: number } { + const now = new Date(); + const end = startOfUtcDay(now); + const days = period === "7d" ? 7 : period === "90d" ? 90 : 30; + const start = addUtcDays(end, -(days - 1)); + + return { start, end, days }; +} + +function toDateKey(input: string): string | null { + const parsed = new Date(input); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.toISOString().slice(0, 10); +} + +function sumCounts(counts: Record): number { + return Object.values(counts).reduce((sum, value) => { + if (!Number.isFinite(value) || value < 0) return sum; + return sum + Math.floor(value); + }, 0); +} + +async function readAgentsFromDataDir(): Promise { + const filePath = path.join(process.cwd(), "data", "agents.json"); + try { + const raw = await fs.readFile(filePath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return 0; + return parsed.length; + } catch { + return null; + } +} + +export async function GET(request: NextRequest) { + const period = parsePeriod(request.nextUrl.searchParams.get("period")); + const { start, days } = getRange(period); + const rangeStartKey = start.toISOString().slice(0, 10); + + const [ + reviews, + skillInstallCounts, + mcpInstallCounts, + skillMetricStore, + bounties, + events, + threads, + agentsFromDataDir, + ] = await Promise.all([ + getAllReviews(), + readSkillInstallCounts(), + readMcpInstallCounts(), + readSkillMetricStore(), + getBounties(), + readEventsFile(), + readCommunityThreadsFile(), + readAgentsFromDataDir(), + ]); + + const skills = getSkills(); + const totalAgents = agentsFromDataDir ?? getAgents().length; + + const reviewMap = new Map(); + for (const review of reviews) { + const current = reviewMap.get(review.skillSlug) ?? { count: 0, ratingSum: 0 }; + current.count += 1; + current.ratingSum += Number.isFinite(review.rating) ? review.rating : 0; + reviewMap.set(review.skillSlug, current); + } + + const topSkills = skills + .map((skill) => { + const baseInstalls = skillInstallCounts[skill.slug] ?? 0; + const trackedInstalls = skillMetricStore.installs_total[skill.slug] ?? 0; + const installs = baseInstalls + trackedInstalls; + const reviewStats = reviewMap.get(skill.slug); + const reviewCount = reviewStats?.count ?? 0; + const averageRating = + reviewCount > 0 ? Number((reviewStats!.ratingSum / reviewCount).toFixed(2)) : 0; + + return { + slug: skill.slug, + name: skill.name, + installs, + reviews: reviewCount, + averageRating, + }; + }) + .sort((a, b) => { + if (b.installs !== a.installs) return b.installs - a.installs; + if (b.reviews !== a.reviews) return b.reviews - a.reviews; + return b.averageRating - a.averageRating; + }) + .slice(0, 10); + + const totalSkillInstalls = sumCounts(skillInstallCounts) + sumCounts(skillMetricStore.installs_total); + const totalMcpInstalls = sumCounts(mcpInstallCounts); + const totalInstalls = totalSkillInstalls + totalMcpInstalls; + + const buckets = new Map(); + for (let i = 0; i < days; i += 1) { + const date = addUtcDays(start, i).toISOString().slice(0, 10); + buckets.set(date, { + date, + count: 0, + events: 0, + bounties: 0, + community: 0, + }); + } + + const addActivity = (dateValue: string, key: keyof Omit) => { + const dateKey = toDateKey(dateValue); + if (!dateKey || dateKey < rangeStartKey) return; + const bucket = buckets.get(dateKey); + if (!bucket) return; + + bucket[key] += 1; + bucket.count += 1; + }; + + for (const event of events) { + addActivity(event.createdAt || event.date, "events"); + } + + for (const bounty of bounties) { + addActivity(bounty.createdAt, "bounties"); + } + + for (const thread of threads) { + addActivity(thread.createdAt, "community"); + for (const reply of thread.replies) { + addActivity(reply.createdAt, "community"); + } + } + + const activity = Array.from(buckets.values()); + + return NextResponse.json( + { + period, + generatedAt: new Date().toISOString(), + summary: { + totalSkills: skills.length, + totalAgents, + totalInstalls, + totalReviews: reviews.length, + }, + topSkills, + activity, + }, + { + headers: { "Cache-Control": "no-store" }, + } + ); +}