/** * Trending algorithm for OpenPrompt * Calculates "hot score" based on recency and engagement */ interface TrendingPrompt { id: string totalRuns: number starsCount: number remixesCount: number createdAt: Date updatedAt: Date } /** * Calculate trending score (Hot Score) * Based on Reddit's "hot" algorithm with OpenPrompt-specific weights * * Score = (engagement) / (age_hours + 2)^1.5 * * Weights: * - Recent activity >> old activity * - Runs: 1 point (high volume, lower signal) * - Stars: 3 points (medium signal — user took action) * - Remixes: 5 points (strongest signal — user remixed the work) */ export function calculateHotScore(prompt: TrendingPrompt): number { const now = new Date() // Calculate age in hours since creation const ageMs = now.getTime() - prompt.createdAt.getTime() const ageHours = ageMs / (1000 * 60 * 60) // Engagement score with weighted components const engagement = prompt.totalRuns * 1 + prompt.starsCount * 3 + prompt.remixesCount * 5 // Hot score formula (prevents division by zero with +2) const score = engagement / Math.pow(ageHours + 2, 1.5) return score } /** * Calculate trending score for today/this week * Only considers recent activity with decay for older prompts */ export function calculateRecentTrendingScore( prompt: TrendingPrompt, timeWindow: 'day' | 'week' = 'week' ): number { const now = new Date() const cutoff = new Date() if (timeWindow === 'day') { cutoff.setDate(now.getDate() - 1) } else { cutoff.setDate(now.getDate() - 7) } // If prompt is older than cutoff, use a decay factor if (prompt.createdAt < cutoff) { const score = calculateHotScore(prompt) // Apply decay (older = lower score) const daysSinceCreation = (now.getTime() - prompt.createdAt.getTime()) / (1000 * 60 * 60 * 24) const decay = Math.exp(-daysSinceCreation / 30) // 30-day half-life return score * decay * 0.5 // Additional penalty for old content } return calculateHotScore(prompt) } /** * Sort prompts by trending score */ export function sortByTrending( prompts: T[], timeWindow?: 'day' | 'week' ): T[] { return [...prompts].sort((a, b) => { const scoreA = timeWindow ? calculateRecentTrendingScore(a, timeWindow) : calculateHotScore(a) const scoreB = timeWindow ? calculateRecentTrendingScore(b, timeWindow) : calculateHotScore(b) return scoreB - scoreA }) } /** * Check if a prompt is currently trending using dynamic percentile thresholds. * * Instead of hardcoded magic numbers, we pass the p75 threshold from the caller * (computed from a Redis-cached or DB-derived percentile of recent scores). * Falls back to a conservative static threshold when no data is available. */ export function isTrending( prompt: TrendingPrompt, threshold?: number, ): boolean { // Must be created within last 7 days to be "trending" const weekAgo = new Date() weekAgo.setDate(weekAgo.getDate() - 7) if (prompt.createdAt < weekAgo) { return false } const promptScore = calculateHotScore(prompt) // If threshold was provided (e.g., from a percentile query), use it if (threshold !== undefined) { return promptScore >= threshold } // Conservative fallback: engagement must exceed "1 star OR 3 runs in <7 days" const minEngagement = prompt.starsCount * 3 + prompt.totalRuns * 1 return promptScore > 0 && minEngagement >= 3 } /** * Compute the hot-score threshold for the p75 of recent prompts. * Call this from the API route and cache the result for 5 minutes. * * @param recentPrompts - array of prompts from the last 7 days * @param percentile - 0-100 (default: 75 = top 25%) */ export function computeTrendingThreshold( recentPrompts: TrendingPrompt[], percentile: number = 75, ): number { if (recentPrompts.length === 0) return 0 const scores = recentPrompts .map((p) => calculateHotScore(p)) .sort((a, b) => a - b) const index = Math.floor((percentile / 100) * scores.length) return scores[Math.min(index, scores.length - 1)] } /** * Get trending badge text based on computed scores */ export function getTrendingBadge( prompt: TrendingPrompt, dayThreshold?: number, weekThreshold?: number, ): { text: string; icon: string } | null { const dayScore = calculateRecentTrendingScore(prompt, 'day') const weekScore = calculateRecentTrendingScore(prompt, 'week') // Use dynamic thresholds if provided, otherwise use relative checks const dayThr = dayThreshold ?? Infinity const weekThr = weekThreshold ?? Infinity if (dayScore > dayThr) { return { text: 'Trending Now', icon: 'Flame' } } if (weekScore > weekThr) { return { text: 'Trending This Week', icon: 'TrendingUp' } } // Fallback: badge based on raw engagement if no thresholds given if (!dayThreshold && !weekThreshold) { const engagement = prompt.totalRuns + prompt.starsCount * 3 + prompt.remixesCount * 5 if (engagement > 100) return { text: 'Trending Now', icon: 'Flame' } if (engagement > 30) return { text: 'Trending This Week', icon: 'TrendingUp' } } return null }