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
This commit is contained in:
Ajax Davis 2026-02-10 02:21:50 +10:00
parent 66fb7ef226
commit 4fa8344b34
24 changed files with 690 additions and 271 deletions

View file

@ -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<string | null>(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;

View file

@ -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;

View file

@ -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<SortOption>('likes');
@ -70,9 +71,10 @@ export default function PublicAgentsPage(): React.ReactElement {
() => (
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
<th className="px-4 py-3 w-[200px]">Name</th>
<th className="px-4 py-3 w-[250px]">Description</th>
<th className="px-4 py-3 w-[200px]">Description</th>
<th className="px-4 py-3 w-[100px]">Provider</th>
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
<th className="px-4 py-3 w-[70px] text-center">Forks</th>
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
<th className="px-4 py-3 w-[150px]">Creator</th>
<th className="px-4 py-3 w-[80px] text-center">Chat</th>
@ -106,6 +108,9 @@ export default function PublicAgentsPage(): React.ReactElement {
{agent.toolCount}
</Badge>
</td>
<td className="px-4 py-3 text-center text-sm text-foreground-secondary">
{agent.forkCount > 0 ? agent.forkCount : '—'}
</td>
<td className="px-4 py-3 text-center">
<LikeButton
entityType="agent"

View file

@ -0,0 +1,87 @@
import { prisma } from '@tpmjs/db';
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 10;
// Only show these activity types publicly (positive actions, not deletions/unlikes)
const PUBLIC_ACTIVITY_TYPES = [
'TOOL_LIKED',
'COLLECTION_CREATED',
'COLLECTION_FORKED',
'COLLECTION_TOOL_ADDED',
'AGENT_CREATED',
'AGENT_FORKED',
'AGENT_LIKED',
'COLLECTION_LIKED',
] as const;
/**
* GET /api/activity/public
* Returns recent public activity for the homepage activity stream.
* Cached for 30 seconds with stale-while-revalidate.
*/
export async function GET() {
try {
const activities = await prisma.userActivity.findMany({
where: {
type: { in: [...PUBLIC_ACTIVITY_TYPES] },
},
orderBy: { createdAt: 'desc' },
take: 20,
select: {
id: true,
type: true,
targetName: true,
targetType: true,
createdAt: true,
user: {
select: {
username: true,
name: true,
},
},
},
});
const data = activities.map((a) => ({
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';
}
}

View file

@ -86,6 +86,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
provider: agent.provider,
modelId: agent.modelId,
likeCount: agent.likeCount,
forkCount: agent.forkCount,
toolCount: agent._count.tools,
collectionCount: agent._count.collections,
createdAt: agent.createdAt,

View file

@ -84,6 +84,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
name: collection.name,
description: collection.description,
likeCount: collection.likeCount,
forkCount: collection.forkCount,
toolCount: collection._count.tools,
createdAt: collection.createdAt,
createdBy: collection.user,

View file

@ -10,6 +10,7 @@ export const maxDuration = 60;
* Captures a daily snapshot of registry statistics for historical tracking.
* Should be run once per day via cron.
*/
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cron handler with many parallel queries
export async function POST(request: NextRequest) {
const startTime = Date.now();
@ -75,6 +76,12 @@ export async function POST(request: NextRequest) {
// Daily health checks
dailyHealthChecks,
// Social proof
activeDevsResult,
publicCollectionsCount,
publicAgentsCount,
totalSimulationsCount,
// Quality distribution
qualityDistribution,
] = await Promise.all([
@ -142,6 +149,17 @@ export async function POST(request: NextRequest) {
where: { createdAt: { gte: yesterday, lt: today } },
}),
// Social proof fields
prisma.userActivity.groupBy({
by: ['userId'],
where: {
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
},
}),
prisma.collection.count({ where: { isPublic: true } }),
prisma.agent.count({ where: { isPublic: true } }),
prisma.simulation.count(),
// Quality score distribution
prisma.$queryRaw<{ bucket: string; count: bigint }[]>`
SELECT
@ -241,6 +259,12 @@ export async function POST(request: NextRequest) {
// Categories
categories,
// Social proof
activeDevs7d: activeDevsResult.length,
totalCollections: publicCollectionsCount,
totalAgents: publicAgentsCount,
totalSimulations: totalSimulationsCount,
},
});

View file

@ -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 }
);
}
}

View file

@ -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<string, number>();
// 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 });
}
}

View file

@ -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<PublicCollection[]>([]);
const [isLoading, setIsLoading] = useState(true);
@ -126,8 +128,9 @@ export default function PublicCollectionsPage(): React.ReactElement {
() => (
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
<th className="px-4 py-3 w-[250px]">Name</th>
<th className="px-4 py-3 w-[300px]">Description</th>
<th className="px-4 py-3 w-[250px]">Description</th>
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
<th className="px-4 py-3 w-[70px] text-center">Forks</th>
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
<th className="px-4 py-3 w-[150px]">Creator</th>
<th className="px-4 py-3 w-[100px] text-right">Copy</th>
@ -159,6 +162,9 @@ export default function PublicCollectionsPage(): React.ReactElement {
{collection.toolCount}
</Badge>
</td>
<td className="px-4 py-3 text-center text-sm text-foreground-secondary">
{collection.forkCount > 0 ? collection.forkCount : '—'}
</td>
<td className="px-4 py-3 text-center">
<LikeButton
entityType="collection"

View file

@ -5,6 +5,7 @@ import { Container } from '@tpmjs/ui/Container/Container';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { AppHeader } from '../components/AppHeader';
import { EcosystemStats } from '../components/home/EcosystemStats';
import { FeaturesSection } from '../components/home/FeaturesSection';
import { HeroSection } from '../components/home/HeroSection';
@ -13,95 +14,127 @@ export const dynamic = 'force-dynamic';
async function getHomePageData() {
try {
// Fetch stats in parallel
const [packageCount, toolCount, featuredTools, categoryStats, featuredScenarios] =
await Promise.all([
// Total package count
prisma.package.count(),
const [
packageCount,
toolCount,
featuredTools,
categoryStats,
featuredScenarios,
latestSnapshot,
] = await Promise.all([
// Total package count
prisma.package.count(),
// Total tool count
prisma.tool.count(),
// Total tool count
prisma.tool.count(),
// Top 6 featured tools by quality score
prisma.tool.findMany({
orderBy: [{ qualityScore: 'desc' }, { package: { npmDownloadsLastMonth: 'desc' } }],
take: 6,
select: {
id: true,
name: true,
description: true,
qualityScore: true,
package: {
// Top 6 featured tools by quality score
prisma.tool.findMany({
orderBy: [{ qualityScore: 'desc' }, { package: { npmDownloadsLastMonth: 'desc' } }],
take: 6,
select: {
id: true,
name: true,
description: true,
qualityScore: true,
likeCount: true,
package: {
select: {
npmPackageName: true,
category: true,
npmDownloadsLastMonth: true,
isOfficial: true,
},
},
},
}),
// Category distribution for stats (group by package category)
prisma.package.groupBy({
by: ['category'],
_count: {
_all: true,
},
}),
// 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: {
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<React.ReactElement> {
{/* Hero Section - Dithered Design */}
<HeroSection stats={data.stats} />
{/* Ecosystem Stats */}
<EcosystemStats stats={data.ecosystemStats} />
{/* Features Section */}
<FeaturesSection />
<FeaturesSection toolCount={data.stats.toolCount} />
{/* Architecture Diagram Section - temporarily disabled
<section className="py-16 bg-background border-b border-border">
@ -200,14 +245,20 @@ export default async function HomePage(): Promise<React.ReactElement> {
</div>
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-foreground-tertiary">
{tool.qualityScore && Number(tool.qualityScore) > 0 ? (
<span className="flex items-center gap-1">
<span className="text-brutalist-accent"></span>
{Number(tool.qualityScore).toFixed(2)}
</span>
) : (
<span />
)}
<div className="flex items-center gap-3">
{tool.qualityScore && Number(tool.qualityScore) > 0 ? (
<span className="flex items-center gap-1">
<span className="text-brutalist-accent"></span>
{Number(tool.qualityScore).toFixed(2)}
</span>
) : null}
{tool.likeCount > 0 && (
<span className="flex items-center gap-1">
<Icon icon="heart" className="w-3 h-3 text-error" />
{tool.likeCount}
</span>
)}
</div>
<span>
{(tool.package.npmDownloadsLastMonth ?? 0) > 0
? `${tool.package.npmDownloadsLastMonth?.toLocaleString()} downloads/mo`
@ -459,7 +510,9 @@ export default async function HomePage(): Promise<React.ReactElement> {
<div className="w-2 h-2 bg-success rounded-full animate-pulse" />
<p className="font-mono text-xs text-foreground-secondary">
add to config instant access to{' '}
<span className="text-primary font-medium">170+ tools</span>
<span className="text-primary font-medium">
{data.stats.toolCount > 0 ? `${data.stats.toolCount}+` : '100+'} tools
</span>
</p>
</div>
</fieldset>

View file

@ -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
<div className="min-h-screen bg-background">
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: required for structured data ld+json
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplicationSchema) }}
/>
<AppHeader />
@ -600,6 +606,22 @@ console.log(result.text);`}
</p>
</div>
)}
{(tool.viewCount ?? 0) > 0 && (
<div>
<p className="text-sm text-foreground-secondary mb-1">Views</p>
<p className="text-2xl font-bold text-foreground">
{tool.viewCount?.toLocaleString()}
</p>
</div>
)}
{(tool.reviewCount ?? 0) > 0 && (
<div>
<p className="text-sm text-foreground-secondary mb-1">Reviews</p>
<p className="text-2xl font-bold text-foreground">
{tool.reviewCount?.toLocaleString()}
</p>
</div>
)}
<div>
<p className="text-sm text-foreground-secondary mb-2">Quality Score</p>
<ProgressBar

View file

@ -79,6 +79,7 @@ async function getTool(slug: string[]): Promise<Tool | null> {
healthCheckError: tool.healthCheckError ?? null,
lastHealthCheck: tool.lastHealthCheck?.toISOString() ?? null,
likeCount: tool.likeCount,
viewCount: tool.viewCount,
averageRating: tool.averageRating?.toString() ?? null,
ratingCount: tool.ratingCount,
reviewCount: tool.reviewCount,

View file

@ -1,18 +1,48 @@
/**
* EcosystemStats Component
*
* Redesigned statistics section with dithered numbers and live activity stream.
*/
'use client';
import { ActivityStream } from '@tpmjs/ui/ActivityStream/ActivityStream';
import { Container } from '@tpmjs/ui/Container/Container';
import { DitherSectionHeader } from '@tpmjs/ui/DitherText/DitherSectionHeader';
import { StatCard } from '@tpmjs/ui/StatCard/StatCard';
import { statistics } from '../../data/homePageData';
import { PublicActivityStream } from './PublicActivityStream';
interface EcosystemStatsProps {
stats: {
publishedTools: number;
activeDevelopers: number;
totalExecutions: number;
avgResponseMs: number | null;
totalDownloads: number;
};
}
export function EcosystemStats({ stats }: EcosystemStatsProps): React.ReactElement {
const statistics = [
{
value: stats.publishedTools,
label: 'Published Tools',
subtext: 'Auto-synced from npm',
suffix: '',
},
{
value: stats.activeDevelopers,
label: 'Active Developers',
subtext: 'Last 7 days',
suffix: '',
},
{
value: stats.totalExecutions,
label: 'Total Executions',
subtext: 'All-time simulations',
suffix: '',
},
{
value: stats.avgResponseMs ?? 0,
label: 'Avg Response',
subtext: 'Execution latency',
suffix: 'ms',
},
];
export function EcosystemStats(): React.ReactElement {
return (
<section className="py-16 md:py-24 bg-surface relative overflow-hidden">
{/* Subtle grid background */}
@ -23,34 +53,28 @@ export function EcosystemStats(): React.ReactElement {
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
{statistics.map((stat, index) => {
// Extract number from value string
const numValue = Number.parseInt(stat.value.replace(/[^0-9]/g, ''), 10) || 0;
const suffix = stat.value.replace(/[0-9,]/g, '');
return (
<div
key={stat.label}
className={`opacity-0 animate-brutalist-entrance stagger-${index + 1}`}
>
<StatCard
value={numValue}
label={stat.label}
subtext={stat.subtext}
suffix={suffix}
variant="brutalist"
size="md"
showBar={true}
barProgress={60 + index * 10}
/>
</div>
);
})}
{statistics.map((stat, index) => (
<div
key={stat.label}
className={`opacity-0 animate-brutalist-entrance stagger-${index + 1}`}
>
<StatCard
value={stat.value}
label={stat.label}
subtext={stat.subtext}
suffix={stat.suffix}
variant="brutalist"
size="md"
showBar={true}
barProgress={60 + index * 10}
/>
</div>
))}
</div>
{/* Activity Stream */}
<div className="max-w-3xl mx-auto">
<ActivityStream updateInterval={6000} maxItems={5} />
<PublicActivityStream />
</div>
</Container>
</section>

View file

@ -18,7 +18,13 @@ interface FeatureCardProps {
href?: string;
}
function FeatureCard({ icon, title, description, badge, href }: FeatureCardProps): React.ReactElement {
function FeatureCard({
icon,
title,
description,
badge,
href,
}: FeatureCardProps): React.ReactElement {
const content = (
<div className="group h-full p-6 border border-dashed border-border bg-surface hover:border-primary hover:bg-primary/5 transition-all duration-200">
{/* Icon */}
@ -60,13 +66,18 @@ function FeatureCard({ icon, title, description, badge, href }: FeatureCardProps
// Main Features Section
// ============================================================================
export function FeaturesSection(): React.ReactElement {
interface FeaturesSectionProps {
toolCount?: number;
}
export function FeaturesSection({ toolCount }: FeaturesSectionProps): React.ReactElement {
const toolCountLabel = toolCount && toolCount > 0 ? `${toolCount.toLocaleString()}` : '100+';
const features = [
{
icon: 'search',
title: 'tool registry',
description:
'Browse 1M+ AI tools from npm. Auto-discovered within minutes of publication with quality scoring and health monitoring.',
description: `Browse ${toolCountLabel} AI tools from npm. Auto-discovered within minutes of publication with quality scoring and health monitoring.`,
badge: 'auto-sync',
href: '/tool/tool-search',
},

View file

@ -10,6 +10,8 @@ interface HeroSectionProps {
packageCount: number;
toolCount: number;
categoryCount: number;
totalDownloads: number;
totalStars: number;
};
}
@ -79,6 +81,15 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
<span className="text-foreground">{formatNumber(stats.toolCount)}</span>
<span className="text-foreground-secondary">TOOLS</span>
</div>
{stats.totalDownloads > 0 && (
<>
<span className="text-foreground-tertiary">/</span>
<div className="flex items-center gap-2">
<span className="text-foreground">{formatNumber(stats.totalDownloads)}</span>
<span className="text-foreground-secondary">DOWNLOADS</span>
</div>
</>
)}
</div>
{/* Subheading */}

View file

@ -0,0 +1,80 @@
'use client';
import { ActivityStream } from '@tpmjs/ui/ActivityStream/ActivityStream';
import { useEffect, useRef, useState } from 'react';
interface ActivityItem {
type: 'invoked' | 'published' | 'updated';
tool: string;
time: string;
}
function formatRelativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
interface PublicActivity {
id: string;
type: 'invoked' | 'published' | 'updated';
username: string;
targetName: string;
targetType: string;
createdAt: string;
}
export function PublicActivityStream(): React.ReactElement {
const [activities, setActivities] = useState<ActivityItem[]>([]);
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
async function fetchActivities() {
try {
const res = await fetch('/api/activity/public');
const json = await res.json();
if (isMounted.current && json.success && json.data.length > 0) {
setActivities(
json.data.map((a: PublicActivity) => ({
type: a.type,
tool: `${a.username}${a.targetName}`,
time: formatRelativeTime(a.createdAt),
}))
);
}
} catch {
// Silent fail — activity stream is non-critical
}
}
// Initial fetch via interval (fires immediately on mount delay, then every 30s)
const timeoutId = setTimeout(fetchActivities, 0);
const intervalId = setInterval(fetchActivities, 30_000);
return () => {
isMounted.current = false;
clearTimeout(timeoutId);
clearInterval(intervalId);
};
}, []);
// Pass activities to the existing ActivityStream component
// If no real activities exist yet, ActivityStream will auto-generate mock activity
return (
<ActivityStream
activities={activities.length > 0 ? activities : undefined}
updateInterval={30_000}
maxItems={5}
/>
);
}

View file

@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
import { useState } from 'react';
export function TechDiagram() {
const [isLoading, setIsLoading] = useState(true);
@ -10,13 +10,8 @@ export function TechDiagram() {
if (hasError) {
return (
<div className="w-full h-[700px] border border-border rounded-lg overflow-hidden flex flex-col items-center justify-center gap-4 p-8 text-center">
<p className="text-foreground-secondary">
The interactive diagram failed to load.
</p>
<a
href="/architecture"
className="text-blue-500 hover:underline text-sm"
>
<p className="text-foreground-secondary">The interactive diagram failed to load.</p>
<a href="/architecture" className="text-blue-500 hover:underline text-sm">
View the alternative architecture diagram &rarr;
</a>
</div>

View file

@ -11,22 +11,6 @@ export interface ToolCard {
href: string;
}
export interface Category {
id: string;
name: string;
icon: IconName;
colorClass: string;
toolCount: number;
href: string;
}
export interface Statistic {
icon: IconName;
value: string;
label: string;
subtext?: string;
}
export const featuredTools: ToolCard[] = [
{
id: 'tool-search',
@ -95,131 +79,8 @@ export const featuredTools: ToolCard[] = [
},
];
export const categories: Category[] = [
{
id: 'web-apis',
name: 'Web & APIs',
icon: 'externalLink',
colorClass: 'bg-blue-500 text-white',
toolCount: 423,
href: '/category/web-apis',
},
{
id: 'databases',
name: 'Databases',
icon: 'copy',
colorClass: 'bg-emerald-500 text-white',
toolCount: 198,
href: '/category/databases',
},
{
id: 'documents',
name: 'Documents',
icon: 'copy',
colorClass: 'bg-amber-500 text-white',
toolCount: 156,
href: '/category/documents',
},
{
id: 'images',
name: 'Images',
icon: 'check',
colorClass: 'bg-pink-500 text-white',
toolCount: 134,
href: '/category/images',
},
{
id: 'email',
name: 'Email',
icon: 'check',
colorClass: 'bg-indigo-500 text-white',
toolCount: 89,
href: '/category/email',
},
{
id: 'calendar',
name: 'Calendar',
icon: 'check',
colorClass: 'bg-orange-500 text-white',
toolCount: 67,
href: '/category/calendar',
},
{
id: 'search',
name: 'Search',
icon: 'check',
colorClass: 'bg-red-500 text-white',
toolCount: 112,
href: '/category/search',
},
{
id: 'code-execution',
name: 'Code Execution',
icon: 'github',
colorClass: 'bg-zinc-700 text-white',
toolCount: 245,
href: '/category/code-execution',
},
{
id: 'communication',
name: 'Communication',
icon: 'check',
colorClass: 'bg-cyan-500 text-white',
toolCount: 178,
href: '/category/communication',
},
{
id: 'analytics',
name: 'Analytics',
icon: 'check',
colorClass: 'bg-violet-500 text-white',
toolCount: 203,
href: '/category/analytics',
},
{
id: 'security',
name: 'Security',
icon: 'check',
colorClass: 'bg-slate-600 text-white',
toolCount: 91,
href: '/category/security',
},
{
id: 'workflows',
name: 'Workflows',
icon: 'check',
colorClass: 'bg-teal-500 text-white',
toolCount: 167,
href: '/category/workflows',
},
];
export const statistics: Statistic[] = [
{
icon: 'copy',
value: '2,847',
label: 'Published Tools',
subtext: 'Across 24 categories',
},
{
icon: 'github',
value: '48K+',
label: 'Active Developers',
subtext: 'Building with TPMJS',
},
{
icon: 'check',
value: '12M+',
label: 'Weekly Invocations',
subtext: 'Across all tools',
},
{
icon: 'chevronDown',
value: '47ms',
label: 'Average Response',
subtext: '95th percentile latency',
},
];
// Note: categories and statistics arrays were removed in favor of real data
// from the database. See EcosystemStats component and getHomePageData() in page.tsx.
export interface ProblemPoint {
title: string;

View file

@ -8,6 +8,7 @@ export interface PublicAgent {
provider: string;
modelId: string;
likeCount: number;
forkCount: number;
toolCount: number;
collectionCount: number;
createdAt: string;

View file

@ -0,0 +1,18 @@
'use client';
import { useEffect, useRef } from 'react';
export function useTrackView(entityType: string, entityId: string) {
const tracked = useRef(false);
useEffect(() => {
if (tracked.current || !entityId) return;
tracked.current = true;
fetch('/api/track/view', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entityType, entityId }),
}).catch(() => {});
}, [entityType, entityId]);
}

View file

@ -67,7 +67,7 @@ function cleanupMemoryStore() {
/**
* Get client identifier from request (IP address)
*/
function getClientId(request: NextRequest): string {
export function getClientId(request: NextRequest): string {
const forwarded = request.headers.get('x-forwarded-for');
const realIp = request.headers.get('x-real-ip');

View file

@ -8,7 +8,7 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL") // Direct connection for migrations (bypasses pooler)
directUrl = env("DATABASE_URL_UNPOOLED") // Direct connection for migrations (bypasses pooler)
}
/// Package table - stores NPM package metadata (package-level)
@ -95,6 +95,9 @@ model Tool {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// View tracking (denormalized, rolled up daily)
viewCount Int @default(0) @map("view_count")
// Rating aggregates
averageRating Decimal? @map("average_rating") @db.Decimal(2, 1) // 1.0 to 5.0
ratingCount Int @default(0) @map("rating_count")
@ -116,6 +119,7 @@ model Tool {
@@index([likeCount])
@@index([averageRating])
@@index([ratingCount])
@@index([viewCount])
@@index([reviewCount])
@@index([importHealth])
@@index([executionHealth])
@ -326,6 +330,12 @@ model StatsSnapshot {
// Category Breakdown (stored as JSON)
categories Json? @db.JsonB
// Social Proof Fields (added for homepage real data)
activeDevs7d Int @default(0) @map("active_devs_7d")
totalCollections Int @default(0) @map("total_collections")
totalAgents Int @default(0) @map("total_agents")
totalSimulations Int @default(0) @map("total_simulations")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
@ -333,6 +343,20 @@ model StatsSnapshot {
@@map("stats_snapshots")
}
/// PageView - daily-bucketed view counts for entities (tools, collections, agents)
model PageView {
id String @id @default(cuid())
entityType String @map("entity_type") @db.VarChar(20) // 'tool' | 'collection' | 'agent'
entityId String @map("entity_id")
viewCount Int @default(0) @map("view_count")
date DateTime @db.Date
@@unique([entityType, entityId, date])
@@index([entityType, entityId])
@@index([date])
@@map("page_views")
}
// ============================================================================
// Better Auth Models
// ============================================================================
@ -442,6 +466,9 @@ model Collection {
isPublic Boolean @default(false) @map("is_public")
likeCount Int @default(0) @map("like_count")
// View tracking (denormalized, rolled up daily)
viewCount Int @default(0) @map("view_count")
// Executor configuration (optional - uses system default if not set)
executorType String? @map("executor_type") @db.VarChar(50)
executorConfig Json? @map("executor_config") @db.JsonB
@ -489,6 +516,7 @@ model Collection {
@@index([slug])
@@index([isPublic])
@@index([likeCount])
@@index([viewCount])
@@index([createdAt])
@@index([forkedFromId])
@@index([forkCount])
@ -567,6 +595,9 @@ model Agent {
isPublic Boolean @default(true) @map("is_public")
likeCount Int @default(0) @map("like_count")
// View tracking (denormalized, rolled up daily)
viewCount Int @default(0) @map("view_count")
// Executor configuration (optional - uses system default if not set)
// Agent executor overrides collection executor overrides system default
executorType String? @map("executor_type") @db.VarChar(50)
@ -597,6 +628,7 @@ model Agent {
@@index([uid])
@@index([isPublic])
@@index([likeCount])
@@index([viewCount])
@@index([createdAt])
@@index([forkedFromId])
@@index([forkCount])

View file

@ -61,6 +61,10 @@
"path": "/api/sync/stats-snapshot",
"schedule": "0 0 * * *"
},
{
"path": "/api/sync/view-rollup",
"schedule": "30 0 * * *"
},
{
"path": "/api/sync/cleanup-activity",
"schedule": "0 3 * * *"