From 4fa8344b348774d89c1c93cd9ba0e35278d4120d Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Tue, 10 Feb 2026 02:21:50 +1000 Subject: [PATCH] feat: replace hardcoded stats with real DB data and add view tracking - Add PageView model for daily-bucketed view tracking - Add viewCount fields to Tool, Collection, Agent models - Add social proof fields to StatsSnapshot - Add POST /api/track/view endpoint with IP-based dedup - Add GET /api/activity/public endpoint for real activity stream - Add /api/sync/view-rollup daily cron for aggregating views - Expand stats-snapshot cron with new social proof queries - Replace hardcoded homepage stats with real DB-driven props - Add downloads to hero metrics strip - Add PublicActivityStream component fetching real UserActivity - Add useTrackView hook for tool, collection, agent detail pages - Add forkCount column to collections and agents listings - Add views/reviews to tool detail statistics sidebar - Remove deprecated hardcoded statistics and categories from homePageData --- .../[username]/agents/[uid]/page.tsx | 5 + .../[slug]/CollectionDetailClient.tsx | 4 + apps/web/src/app/agents/page.tsx | 7 +- apps/web/src/app/api/activity/public/route.ts | 87 +++++++ apps/web/src/app/api/public/agents/route.ts | 1 + .../src/app/api/public/collections/route.ts | 1 + .../src/app/api/sync/stats-snapshot/route.ts | 24 ++ .../web/src/app/api/sync/view-rollup/route.ts | 86 +++++++ apps/web/src/app/api/track/view/route.ts | 87 +++++++ apps/web/src/app/collections/page.tsx | 8 +- apps/web/src/app/page.tsx | 215 +++++++++++------- .../app/tool/[...slug]/ToolDetailClient.tsx | 22 ++ apps/web/src/app/tool/[...slug]/page.tsx | 1 + .../src/components/home/EcosystemStats.tsx | 90 +++++--- .../src/components/home/FeaturesSection.tsx | 19 +- apps/web/src/components/home/HeroSection.tsx | 11 + .../components/home/PublicActivityStream.tsx | 80 +++++++ apps/web/src/components/tech/TechDiagram.tsx | 11 +- apps/web/src/data/homePageData.ts | 143 +----------- apps/web/src/hooks/useAgents.ts | 1 + apps/web/src/hooks/useTrackView.ts | 18 ++ apps/web/src/lib/rate-limit.ts | 2 +- packages/db/prisma/schema.prisma | 34 ++- vercel.json | 4 + 24 files changed, 690 insertions(+), 271 deletions(-) create mode 100644 apps/web/src/app/api/activity/public/route.ts create mode 100644 apps/web/src/app/api/sync/view-rollup/route.ts create mode 100644 apps/web/src/app/api/track/view/route.ts create mode 100644 apps/web/src/components/home/PublicActivityStream.tsx create mode 100644 apps/web/src/hooks/useTrackView.ts diff --git a/apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx b/apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx index f876f1a..1557766 100644 --- a/apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx +++ b/apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx @@ -11,6 +11,7 @@ import { AppHeader } from '~/components/AppHeader'; import { ForkButton } from '~/components/ForkButton'; import { ForkedFromBadge } from '~/components/ForkedFromBadge'; import { LikeButton } from '~/components/LikeButton'; +import { useTrackView } from '~/hooks/useTrackView'; import { useSession } from '~/lib/auth-client'; interface AgentTool { @@ -183,6 +184,7 @@ const response = await fetch(\`${apiUrl}/\${conversation.id}\`, { ); } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large detail page with many conditional sections export default function PrettyAgentDetailPage(): React.ReactElement { const params = useParams(); const rawUsername = params.username as string; @@ -194,6 +196,9 @@ export default function PrettyAgentDetailPage(): React.ReactElement { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + // Track page view + useTrackView('agent', agent?.id ?? ''); + // Check if current user is the owner const isOwner = session?.user?.id && agent?.createdBy?.id === session.user.id; diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx index 9c91e0f..b38c59c 100644 --- a/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx @@ -14,6 +14,7 @@ import { ScenariosSection } from '~/components/ScenariosSection'; import { ShareButton } from '~/components/ShareButton'; import { SkillsSection } from '~/components/skills/SkillsSection'; import { UseCasesSection } from '~/components/UseCasesSection'; +import { useTrackView } from '~/hooks/useTrackView'; import { useSession } from '~/lib/auth-client'; /** @@ -313,6 +314,9 @@ export function CollectionDetailClient({ const { data: session } = useSession(); const [collection, setCollection] = useState(initialCollection); + // Track page view + useTrackView('collection', collection.id); + // Check if current user is the owner const isOwner = session?.user?.id && collection.createdBy?.id === session.user.id; diff --git a/apps/web/src/app/agents/page.tsx b/apps/web/src/app/agents/page.tsx index 60df41b..bb1e260 100644 --- a/apps/web/src/app/agents/page.tsx +++ b/apps/web/src/app/agents/page.tsx @@ -38,6 +38,7 @@ function truncateText(text: string, maxLength: number): string { return `${text.slice(0, maxLength).trim()}...`; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large page component with table rendering export default function PublicAgentsPage(): React.ReactElement { const [search, setSearch] = useState(''); const [sort, setSort] = useState('likes'); @@ -70,9 +71,10 @@ export default function PublicAgentsPage(): React.ReactElement { () => ( Name - Description + Description Provider Tools + Forks Likes Creator Chat @@ -106,6 +108,9 @@ export default function PublicAgentsPage(): React.ReactElement { {agent.toolCount} + + {agent.forkCount > 0 ? agent.forkCount : '—'} + ({ + id: a.id, + type: mapActivityType(a.type), + username: a.user.username || a.user.name, + targetName: a.targetName, + targetType: a.targetType, + createdAt: a.createdAt, + })); + + return NextResponse.json( + { success: true, data }, + { + headers: { + 'Cache-Control': 's-maxage=30, stale-while-revalidate=60', + }, + } + ); + } catch (error) { + console.error('[activity/public] Error:', error); + return NextResponse.json({ success: true, data: [] }); + } +} + +function mapActivityType(type: string): 'invoked' | 'published' | 'updated' { + switch (type) { + case 'TOOL_LIKED': + case 'COLLECTION_LIKED': + case 'AGENT_LIKED': + return 'invoked'; + case 'COLLECTION_CREATED': + case 'AGENT_CREATED': + return 'published'; + case 'COLLECTION_FORKED': + case 'AGENT_FORKED': + case 'COLLECTION_TOOL_ADDED': + return 'updated'; + default: + return 'updated'; + } +} diff --git a/apps/web/src/app/api/public/agents/route.ts b/apps/web/src/app/api/public/agents/route.ts index 2bd4217..847688f 100644 --- a/apps/web/src/app/api/public/agents/route.ts +++ b/apps/web/src/app/api/public/agents/route.ts @@ -86,6 +86,7 @@ export async function GET(request: NextRequest): Promise` SELECT @@ -241,6 +259,12 @@ export async function POST(request: NextRequest) { // Categories categories, + + // Social proof + activeDevs7d: activeDevsResult.length, + totalCollections: publicCollectionsCount, + totalAgents: publicAgentsCount, + totalSimulations: totalSimulationsCount, }, }); diff --git a/apps/web/src/app/api/sync/view-rollup/route.ts b/apps/web/src/app/api/sync/view-rollup/route.ts new file mode 100644 index 0000000..4e47902 --- /dev/null +++ b/apps/web/src/app/api/sync/view-rollup/route.ts @@ -0,0 +1,86 @@ +import { prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; + +/** + * POST /api/sync/view-rollup + * Daily cron: aggregates PageView counts into denormalized viewCount fields + * on Tool, Collection, and Agent models. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cron handler with sequential entity type processing +export async function POST(request: NextRequest) { + const startTime = Date.now(); + + // Verify cron secret + const authHeader = request.headers.get('authorization'); + const cronSecret = process.env.CRON_SECRET; + + if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + // Aggregate views by entity type and entity ID (all-time sum) + const entityTypes = ['tool', 'collection', 'agent'] as const; + let totalUpdated = 0; + + for (const entityType of entityTypes) { + // Get aggregated view counts per entity + const viewCounts = await prisma.pageView.groupBy({ + by: ['entityId'], + where: { entityType }, + _sum: { viewCount: true }, + }); + + // Update denormalized viewCount on each entity + for (const vc of viewCounts) { + const totalViews = vc._sum.viewCount || 0; + if (totalViews === 0) continue; + + try { + if (entityType === 'tool') { + await prisma.tool.update({ + where: { id: vc.entityId }, + data: { viewCount: totalViews }, + }); + } else if (entityType === 'collection') { + await prisma.collection.update({ + where: { id: vc.entityId }, + data: { viewCount: totalViews }, + }); + } else if (entityType === 'agent') { + await prisma.agent.update({ + where: { id: vc.entityId }, + data: { viewCount: totalViews }, + }); + } + totalUpdated++; + } catch { + // Entity may have been deleted - skip silently + } + } + } + + const durationMs = Date.now() - startTime; + + return NextResponse.json({ + success: true, + data: { + totalUpdated, + durationMs, + }, + }); + } catch (error) { + console.error('[sync/view-rollup] Error:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/track/view/route.ts b/apps/web/src/app/api/track/view/route.ts new file mode 100644 index 0000000..03b57a7 --- /dev/null +++ b/apps/web/src/app/api/track/view/route.ts @@ -0,0 +1,87 @@ +import { prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; +import { getClientId } from '~/lib/rate-limit'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 10; + +const VALID_ENTITY_TYPES = ['tool', 'collection', 'agent'] as const; + +// Simple in-memory dedup: 1 view per entity per IP per hour +const recentViews = new Map(); + +// Clean up every 10 minutes +setInterval(() => { + const now = Date.now(); + for (const [key, timestamp] of recentViews) { + if (now - timestamp > 3600_000) { + recentViews.delete(key); + } + } + // Prevent unbounded growth + if (recentViews.size > 50_000) { + recentViews.clear(); + } +}, 600_000); + +/** + * POST /api/track/view + * Fire-and-forget view tracking. Upserts into PageView with daily bucket. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { entityType, entityId } = body; + + // Validate input + if (!entityType || !entityId) { + return NextResponse.json({ error: 'Missing entityType or entityId' }, { status: 400 }); + } + + if (!VALID_ENTITY_TYPES.includes(entityType)) { + return NextResponse.json({ error: 'Invalid entityType' }, { status: 400 }); + } + + // Rate-limit: 1 view per entity per IP per hour + const clientId = getClientId(request); + const dedupKey = `${clientId}:${entityType}:${entityId}`; + const lastView = recentViews.get(dedupKey); + + if (lastView && Date.now() - lastView < 3600_000) { + return NextResponse.json({ ok: true, deduped: true }); + } + + recentViews.set(dedupKey, Date.now()); + + // Today's date bucket (midnight UTC) + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + // Upsert page view (fire-and-forget style, don't await in production but we need to for correctness) + await prisma.pageView.upsert({ + where: { + entityType_entityId_date: { + entityType, + entityId, + date: today, + }, + }, + create: { + entityType, + entityId, + date: today, + viewCount: 1, + }, + update: { + viewCount: { increment: 1 }, + }, + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + // Silently fail - view tracking should never break the user experience + console.error('[track/view] Error:', error); + return NextResponse.json({ ok: true }); + } +} diff --git a/apps/web/src/app/collections/page.tsx b/apps/web/src/app/collections/page.tsx index 1a4e359..7dc5a65 100644 --- a/apps/web/src/app/collections/page.tsx +++ b/apps/web/src/app/collections/page.tsx @@ -21,6 +21,7 @@ interface PublicCollection { name: string; description: string | null; likeCount: number; + forkCount: number; toolCount: number; createdAt: string; createdBy: { @@ -53,6 +54,7 @@ function truncateText(text: string, maxLength: number): string { return `${text.slice(0, maxLength).trim()}...`; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large page component with table rendering export default function PublicCollectionsPage(): React.ReactElement { const [collections, setCollections] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -126,8 +128,9 @@ export default function PublicCollectionsPage(): React.ReactElement { () => ( Name - Description + Description Tools + Forks Likes Creator Copy @@ -159,6 +162,9 @@ export default function PublicCollectionsPage(): React.ReactElement { {collection.toolCount} + + {collection.forkCount > 0 ? collection.forkCount : '—'} + { + // Get high quality scenarios + const highQuality = await prisma.scenario.findMany({ + where: { + collection: { isPublic: true }, + qualityScore: { gte: 0.3 }, + totalRuns: { gte: 1 }, + }, + orderBy: { qualityScore: 'desc' }, + take: 3, + include: { + collection: { select: { - npmPackageName: true, - category: true, - npmDownloadsLastMonth: true, - isOfficial: true, + id: true, + name: true, + slug: true, + user: { select: { username: true } }, }, }, }, - }), + }); - // Category distribution for stats (group by package category) - prisma.package.groupBy({ - by: ['category'], - _count: { - _all: true, + // Get fresh scenarios (excluding already selected) + const seenIds = new Set(highQuality.map((s) => s.id)); + const fresh = await prisma.scenario.findMany({ + where: { + collection: { isPublic: true }, + id: { notIn: Array.from(seenIds) }, }, - }), - - // Featured scenarios - mix of high quality, diverse, and fresh - (async () => { - // Get high quality scenarios - const highQuality = await prisma.scenario.findMany({ - where: { - collection: { isPublic: true }, - qualityScore: { gte: 0.3 }, - totalRuns: { gte: 1 }, - }, - orderBy: { qualityScore: 'desc' }, - take: 3, - include: { - collection: { - select: { - id: true, - name: true, - slug: true, - user: { select: { username: true } }, - }, + orderBy: { createdAt: 'desc' }, + take: 3, + include: { + collection: { + select: { + id: true, + name: true, + slug: true, + user: { select: { username: true } }, }, }, - }); + }, + }); - // Get fresh scenarios (excluding already selected) - const seenIds = new Set(highQuality.map((s) => s.id)); - const fresh = await prisma.scenario.findMany({ - where: { - collection: { isPublic: true }, - id: { notIn: Array.from(seenIds) }, - }, - orderBy: { createdAt: 'desc' }, - take: 3, - include: { - collection: { - select: { - id: true, - name: true, - slug: true, - user: { select: { username: true } }, - }, - }, - }, - }); + return [...highQuality, ...fresh].slice(0, 6); + })(), - return [...highQuality, ...fresh].slice(0, 6); - })(), - ]); + // Latest stats snapshot (pre-computed daily) + prisma.statsSnapshot.findFirst({ + orderBy: { date: 'desc' }, + select: { + totalTools: true, + totalPackages: true, + totalNpmDownloads: true, + totalGithubStars: true, + executionsTotal: true, + executionsAvgTimeMs: true, + activeDevs7d: true, + totalSimulations: true, + categories: true, + }, + }), + ]); return { stats: { packageCount, toolCount, categoryCount: categoryStats.length, + totalDownloads: latestSnapshot?.totalNpmDownloads ?? 0, + totalStars: latestSnapshot?.totalGithubStars ?? 0, + }, + ecosystemStats: { + publishedTools: latestSnapshot?.totalTools ?? toolCount, + activeDevelopers: latestSnapshot?.activeDevs7d ?? 0, + totalExecutions: latestSnapshot?.totalSimulations ?? 0, + avgResponseMs: latestSnapshot?.executionsAvgTimeMs ?? null, + totalDownloads: latestSnapshot?.totalNpmDownloads ?? 0, }, featuredTools, categories: categoryStats.slice(0, 5).map((c) => ({ @@ -117,6 +150,15 @@ async function getHomePageData() { packageCount: 0, toolCount: 0, categoryCount: 0, + totalDownloads: 0, + totalStars: 0, + }, + ecosystemStats: { + publishedTools: 0, + activeDevelopers: 0, + totalExecutions: 0, + avgResponseMs: null, + totalDownloads: 0, }, featuredTools: [], categories: [], @@ -135,8 +177,11 @@ export default async function HomePage(): Promise { {/* Hero Section - Dithered Design */} + {/* Ecosystem Stats */} + + {/* Features Section */} - + {/* Architecture Diagram Section - temporarily disabled
@@ -200,14 +245,20 @@ export default async function HomePage(): Promise {
- {tool.qualityScore && Number(tool.qualityScore) > 0 ? ( - - - {Number(tool.qualityScore).toFixed(2)} - - ) : ( - - )} +
+ {tool.qualityScore && Number(tool.qualityScore) > 0 ? ( + + + {Number(tool.qualityScore).toFixed(2)} + + ) : null} + {tool.likeCount > 0 && ( + + + {tool.likeCount} + + )} +
{(tool.package.npmDownloadsLastMonth ?? 0) > 0 ? `${tool.package.npmDownloadsLastMonth?.toLocaleString()} downloads/mo` @@ -459,7 +510,9 @@ export default async function HomePage(): Promise {

add to config → instant access to{' '} - 170+ tools + + {data.stats.toolCount > 0 ? `${data.stats.toolCount}+` : '100+'} tools +

diff --git a/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx b/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx index 98897d5..2f973fa 100644 --- a/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx +++ b/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx @@ -17,6 +17,7 @@ import { LikeButton } from '~/components/LikeButton'; import { Markdown } from '~/components/Markdown'; import { Rating } from '~/components/Rating'; import { ToolPlayground } from '~/components/ToolPlayground'; +import { useTrackView } from '~/hooks/useTrackView'; interface Package { id: string; @@ -70,6 +71,7 @@ export interface Tool { healthCheckError?: string | null; lastHealthCheck?: string | null; likeCount?: number; + viewCount?: number; averageRating?: string | null; ratingCount?: number; reviewCount?: number; @@ -88,6 +90,9 @@ export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.R const [recheckLoading, setRecheckLoading] = useState(false); const [extractSchemaLoading, setExtractSchemaLoading] = useState(false); + // Track page view + useTrackView('tool', tool.id); + const pkg = tool.package; const authorName = typeof pkg.npmAuthor === 'string' ? pkg.npmAuthor : pkg.npmAuthor?.name; @@ -194,6 +199,7 @@ export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.R