Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 85 additions & 15 deletions src/app/discover/page.tsx
Original file line number Diff line number Diff line change
@@ -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 }>;

Expand Down Expand Up @@ -35,9 +38,9 @@ export default async function DiscoverPage({

<TabNav tabs={TABS} active={tab} className="mt-6" />

{(tab === "trending" || tab === "rising") && (
{tab === "rising" && (
<p className="text-muted mt-4 text-xs italic">
Ranking by recency — trading-based ranking available after Phase 5.
Acceleration ranking requires a trades indexer — showing recent active stories.
</p>
)}

Expand All @@ -55,6 +58,19 @@ export default async function DiscoverPage({
);
}

/** Read totalSupply for a token, returns 0 on failure */
async function readSupply(tokenAddress: string): Promise<bigint> {
try {
return await publicClient.readContract({
address: tokenAddress as Address,
abi: erc20Abi,
functionName: "totalSupply",
});
} catch {
return BigInt(0);
}
}

async function queryTab(
supabase: ReturnType<typeof createServerClient> & object,
tab: Tab,
Expand Down Expand Up @@ -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<Storyline[]>();
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<Storyline[]>();
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);
}
}
}
Loading