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:
parent
66fb7ef226
commit
4fa8344b34
24 changed files with 690 additions and 271 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
87
apps/web/src/app/api/activity/public/route.ts
Normal file
87
apps/web/src/app/api/activity/public/route.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
86
apps/web/src/app/api/sync/view-rollup/route.ts
Normal file
86
apps/web/src/app/api/sync/view-rollup/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
87
apps/web/src/app/api/track/view/route.ts
Normal file
87
apps/web/src/app/api/track/view/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,8 +14,14 @@ export const dynamic = 'force-dynamic';
|
|||
async function getHomePageData() {
|
||||
try {
|
||||
// Fetch stats in parallel
|
||||
const [packageCount, toolCount, featuredTools, categoryStats, featuredScenarios] =
|
||||
await Promise.all([
|
||||
const [
|
||||
packageCount,
|
||||
toolCount,
|
||||
featuredTools,
|
||||
categoryStats,
|
||||
featuredScenarios,
|
||||
latestSnapshot,
|
||||
] = await Promise.all([
|
||||
// Total package count
|
||||
prisma.package.count(),
|
||||
|
||||
|
|
@ -30,6 +37,7 @@ async function getHomePageData() {
|
|||
name: true,
|
||||
description: true,
|
||||
qualityScore: true,
|
||||
likeCount: true,
|
||||
package: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
|
|
@ -95,6 +103,22 @@ async function getHomePageData() {
|
|||
|
||||
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 {
|
||||
|
|
@ -102,6 +126,15 @@ async function getHomePageData() {
|
|||
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">
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
{statistics.map((stat, index) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className={`opacity-0 animate-brutalist-entrance stagger-${index + 1}`}
|
||||
>
|
||||
<StatCard
|
||||
value={numValue}
|
||||
value={stat.value}
|
||||
label={stat.label}
|
||||
subtext={stat.subtext}
|
||||
suffix={suffix}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
80
apps/web/src/components/home/PublicActivityStream.tsx
Normal file
80
apps/web/src/components/home/PublicActivityStream.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 →
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export interface PublicAgent {
|
|||
provider: string;
|
||||
modelId: string;
|
||||
likeCount: number;
|
||||
forkCount: number;
|
||||
toolCount: number;
|
||||
collectionCount: number;
|
||||
createdAt: string;
|
||||
|
|
|
|||
18
apps/web/src/hooks/useTrackView.ts
Normal file
18
apps/web/src/hooks/useTrackView.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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 * * *"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue