diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx index f742e8af..f9160d30 100644 --- a/src/app/discover/page.tsx +++ b/src/app/discover/page.tsx @@ -1,6 +1,9 @@ import { createServerClient, type Storyline } from "../../../lib/supabase"; import { StoryCard } from "../../components/StoryCard"; import { TabNav } from "../../components/TabNav"; +import { publicClient } from "../../../lib/rpc"; +import { erc20Abi } from "../../../lib/price"; +import { type Address } from "viem"; type SearchParams = Promise<{ tab?: string }>; @@ -35,9 +38,9 @@ export default async function DiscoverPage({ - {(tab === "trending" || tab === "rising") && ( + {tab === "rising" && (

- Ranking by recency — trading-based ranking available after Phase 5. + Acceleration ranking requires a trades indexer — showing recent active stories.

)} @@ -55,6 +58,19 @@ export default async function DiscoverPage({ ); } +/** Read totalSupply for a token, returns 0 on failure */ +async function readSupply(tokenAddress: string): Promise { + try { + return await publicClient.readContract({ + address: tokenAddress as Address, + abi: erc20Abi, + functionName: "totalSupply", + }); + } catch { + return BigInt(0); + } +} + async function queryTab( supabase: ReturnType & object, tab: Tab, @@ -84,34 +100,88 @@ async function queryTab( return data ?? []; } - // TODO [Phase 5 / P5-6]: Replace with composite ranking signals - // (unique buyer count, holder diversity, recent trading activity). - // See ROADMAP.md P5-6a for the ranking formula spec. case "trending": { - const { data } = await supabase + // Composite ranking using available on-chain + DB signals: + // - totalSupply (on-chain minting volume) + // - plot_count (content engagement) + // - recency bonus (newer stories weighted higher) + // Full composite (unique buyers, holder diversity) requires a trades + // indexer — will replace these proxies when that exists. + const { data: allStorylines } = await supabase .from("storylines") .select("*") .eq("hidden", false) .eq("sunset", false) - .order("block_timestamp", { ascending: false }) - .limit(50) .returns(); - return data ?? []; + + const storylines = allStorylines ?? []; + const withTokens = storylines.filter((s) => s.token_address); + if (withTokens.length === 0) { + return storylines + .sort((a, b) => (b.block_timestamp ?? "").localeCompare(a.block_timestamp ?? "")) + .slice(0, 50); + } + + const supplies = await Promise.all( + withTokens.map((s) => readSupply(s.token_address)), + ); + + const maxSupply = supplies.reduce((a, b) => (a > b ? a : b), BigInt(1)); + const now = Date.now(); + + const scored = withTokens + .map((s, i) => { + // Normalize supply to 0-100 + const supplyScore = Number((supplies[i] * BigInt(100)) / maxSupply); + // Content engagement: plot_count (capped at 20 for normalization) + const plotScore = Math.min(s.plot_count, 20) * 5; + // Recency: bonus for stories created in last 14 days + const ageMs = s.block_timestamp + ? now - new Date(s.block_timestamp).getTime() + : now; + const ageDays = ageMs / (1000 * 60 * 60 * 24); + const recencyScore = ageDays < 14 ? Math.round((14 - ageDays) * 3) : 0; + + return { + storyline: s, + score: supplyScore + plotScore + recencyScore, + }; + }) + .filter((s) => s.score > 0); + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 50).map((s) => s.storyline); } - // TODO [Phase 5 / P5-6]: Replace with acceleration-based ranking - // (stories with accelerating trading activity vs prior period). - // See ROADMAP.md P5-6b for the rising formula spec. case "rising": { - const { data } = await supabase + // Full acceleration ranking (recent vs prior period trading activity) + // requires a trades indexer. Falling back to recently active stories + // with tokens (stories with supply > 0, ordered by recency). + const { data: allStorylines } = await supabase .from("storylines") .select("*") .eq("hidden", false) .eq("sunset", false) .order("block_timestamp", { ascending: false }) - .limit(50) .returns(); - return data ?? []; + + const storylines = allStorylines ?? []; + const withTokens = storylines.filter((s) => s.token_address); + if (withTokens.length === 0) { + return storylines.slice(0, 50); + } + + // Filter to stories with active supply (any minting activity) + const supplies = await Promise.all( + withTokens.map((s) => readSupply(s.token_address)), + ); + + const active = withTokens.filter((_, i) => supplies[i] > BigInt(0)); + if (active.length === 0) { + return storylines.slice(0, 50); + } + + return active.slice(0, 50); } } }