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