From 06e675249cb06670f79ed8134886a4a7e1a68138 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:13:19 +0000 Subject: [PATCH 1/4] [#28] Implement trending and rising discovery tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #28 - P5-6a: Trending tab ranks by composite signal: donation_count + 2 * unique_donors in last 7 days (diversity weighted per §6.3) - P5-6b: Rising tab ranks by acceleration: stories with more donations in last 3 days vs prior 3 days - Both tabs fall back to newest if no trading activity - Removed Phase 5 placeholder notice Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/discover/page.tsx | 147 ++++++++++++++++++++++++++++++++------ 1 file changed, 124 insertions(+), 23 deletions(-) diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx index f742e8af..6ad3f40d 100644 --- a/src/app/discover/page.tsx +++ b/src/app/discover/page.tsx @@ -1,4 +1,4 @@ -import { createServerClient, type Storyline } from "../../../lib/supabase"; +import { createServerClient, type Storyline, type Donation } from "../../../lib/supabase"; import { StoryCard } from "../../components/StoryCard"; import { TabNav } from "../../components/TabNav"; @@ -35,12 +35,6 @@ export default async function DiscoverPage({ - {(tab === "trending" || tab === "rising") && ( -

- Ranking by recency — trading-based ranking available after Phase 5. -

- )} -
{storylines.map((s) => ( @@ -55,6 +49,12 @@ export default async function DiscoverPage({ ); } +interface DonationAgg { + storyline_id: number; + donation_count: number; + unique_donors: number; +} + async function queryTab( supabase: ReturnType & object, tab: Tab, @@ -84,34 +84,135 @@ 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: donation count + unique donors in last 7 days + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + + const { data: donations } = await supabase + .from("donations") + .select("storyline_id, donor_address") + .gte("block_timestamp", since) + .returns[]>(); + + // Aggregate per storyline + const aggMap = new Map }>(); + for (const d of donations ?? []) { + const entry = aggMap.get(d.storyline_id) ?? { count: 0, donors: new Set() }; + entry.count++; + entry.donors.add(d.donor_address); + aggMap.set(d.storyline_id, entry); + } + + const ranked: DonationAgg[] = []; + for (const [storyline_id, entry] of aggMap) { + ranked.push({ + storyline_id, + donation_count: entry.count, + unique_donors: entry.donors.size, + }); + } + + // Composite score: donation_count + 2 * unique_donors (diversity weighted) + ranked.sort( + (a, b) => + b.donation_count + 2 * b.unique_donors - + (a.donation_count + 2 * a.unique_donors), + ); + + const topIds = ranked.slice(0, 50).map((r) => r.storyline_id); + if (topIds.length === 0) { + // Fallback to newest if no trading activity + const { data } = await supabase + .from("storylines") + .select("*") + .eq("hidden", false) + .eq("sunset", false) + .order("block_timestamp", { ascending: false }) + .limit(50) + .returns(); + return data ?? []; + } + + const { data: storylines } = await supabase .from("storylines") .select("*") .eq("hidden", false) - .eq("sunset", false) - .order("block_timestamp", { ascending: false }) - .limit(50) + .in("storyline_id", topIds) .returns(); - return data ?? []; + + // Re-sort by ranked order + const idOrder = new Map(topIds.map((id, i) => [id, i])); + return (storylines ?? []).sort( + (a, b) => (idOrder.get(a.storyline_id) ?? 99) - (idOrder.get(b.storyline_id) ?? 99), + ); } - // 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 + // Acceleration: more donations in last 3 days vs prior 3 days + const now = Date.now(); + const recentSince = new Date(now - 3 * 24 * 60 * 60 * 1000).toISOString(); + const priorSince = new Date(now - 6 * 24 * 60 * 60 * 1000).toISOString(); + + const { data: recentDonations } = await supabase + .from("donations") + .select("storyline_id") + .gte("block_timestamp", recentSince) + .returns[]>(); + + const { data: priorDonations } = await supabase + .from("donations") + .select("storyline_id") + .gte("block_timestamp", priorSince) + .lt("block_timestamp", recentSince) + .returns[]>(); + + // Count per storyline in each period + const recentCounts = new Map(); + for (const d of recentDonations ?? []) { + recentCounts.set(d.storyline_id, (recentCounts.get(d.storyline_id) ?? 0) + 1); + } + + const priorCounts = new Map(); + for (const d of priorDonations ?? []) { + priorCounts.set(d.storyline_id, (priorCounts.get(d.storyline_id) ?? 0) + 1); + } + + // Compute acceleration: recent - prior (only positive acceleration) + const accelerating: { storyline_id: number; accel: number }[] = []; + for (const [id, recent] of recentCounts) { + const prior = priorCounts.get(id) ?? 0; + const accel = recent - prior; + if (accel > 0) { + accelerating.push({ storyline_id: id, accel }); + } + } + + accelerating.sort((a, b) => b.accel - a.accel); + + const topIds = accelerating.slice(0, 50).map((r) => r.storyline_id); + if (topIds.length === 0) { + const { data } = await supabase + .from("storylines") + .select("*") + .eq("hidden", false) + .eq("sunset", false) + .order("block_timestamp", { ascending: false }) + .limit(50) + .returns(); + return data ?? []; + } + + const { data: storylines } = await supabase .from("storylines") .select("*") .eq("hidden", false) - .eq("sunset", false) - .order("block_timestamp", { ascending: false }) - .limit(50) + .in("storyline_id", topIds) .returns(); - return data ?? []; + + const idOrder = new Map(topIds.map((id, i) => [id, i])); + return (storylines ?? []).sort( + (a, b) => (idOrder.get(a.storyline_id) ?? 99) - (idOrder.get(b.storyline_id) ?? 99), + ); } } } From e53596f51b3cceb5c5b49526f9ae1e410eee2735 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:15:41 +0000 Subject: [PATCH 2/4] [#28] Rework ranking to use trading signals (totalSupply + buyers) Trending now uses on-chain totalSupply (minting volume proxy) combined with unique buyer count. Rising uses acceleration of trading activity between periods. Both filter to storylines with token addresses. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/discover/page.tsx | 160 +++++++++++++++++++------------------- 1 file changed, 78 insertions(+), 82 deletions(-) diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx index 6ad3f40d..78148ef8 100644 --- a/src/app/discover/page.tsx +++ b/src/app/discover/page.tsx @@ -1,6 +1,9 @@ import { createServerClient, type Storyline, type Donation } 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 }>; @@ -49,10 +52,17 @@ export default async function DiscoverPage({ ); } -interface DonationAgg { - storyline_id: number; - donation_count: number; - unique_donors: number; +/** 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( @@ -85,70 +95,73 @@ async function queryTab( } case "trending": { - // Composite ranking: donation count + unique donors in last 7 days - const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + // Fetch active storylines with token addresses + const { data: allStorylines } = await supabase + .from("storylines") + .select("*") + .eq("hidden", false) + .eq("sunset", false) + .returns(); + + 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); + } + // Read on-chain totalSupply (trading volume proxy) for each token + const supplies = await Promise.all( + withTokens.map((s) => readSupply(s.token_address)), + ); + + // Fetch recent unique buyers (donation donors as buyer proxy) in last 7 days + const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const { data: donations } = await supabase .from("donations") .select("storyline_id, donor_address") .gte("block_timestamp", since) .returns[]>(); - // Aggregate per storyline - const aggMap = new Map }>(); + const donorMap = new Map>(); for (const d of donations ?? []) { - const entry = aggMap.get(d.storyline_id) ?? { count: 0, donors: new Set() }; - entry.count++; - entry.donors.add(d.donor_address); - aggMap.set(d.storyline_id, entry); - } - - const ranked: DonationAgg[] = []; - for (const [storyline_id, entry] of aggMap) { - ranked.push({ - storyline_id, - donation_count: entry.count, - unique_donors: entry.donors.size, - }); + const set = donorMap.get(d.storyline_id) ?? new Set(); + set.add(d.donor_address); + donorMap.set(d.storyline_id, set); } - // Composite score: donation_count + 2 * unique_donors (diversity weighted) - ranked.sort( - (a, b) => - b.donation_count + 2 * b.unique_donors - - (a.donation_count + 2 * a.unique_donors), - ); - - const topIds = ranked.slice(0, 50).map((r) => r.storyline_id); - if (topIds.length === 0) { - // Fallback to newest if no trading activity - const { data } = await supabase - .from("storylines") - .select("*") - .eq("hidden", false) - .eq("sunset", false) - .order("block_timestamp", { ascending: false }) - .limit(50) - .returns(); - return data ?? []; - } + // Composite score: normalized supply + 2 * unique buyers + const maxSupply = supplies.reduce((a, b) => (a > b ? a : b), BigInt(1)); + const scored = withTokens.map((s, i) => ({ + storyline: s, + score: + Number((supplies[i] * BigInt(100)) / maxSupply) + + 2 * (donorMap.get(s.storyline_id)?.size ?? 0), + })); + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 50).map((s) => s.storyline); + } - const { data: storylines } = await supabase + case "rising": { + // Fetch active storylines with tokens + const { data: allStorylines } = await supabase .from("storylines") .select("*") .eq("hidden", false) - .in("storyline_id", topIds) + .eq("sunset", false) .returns(); - // Re-sort by ranked order - const idOrder = new Map(topIds.map((id, i) => [id, i])); - return (storylines ?? []).sort( - (a, b) => (idOrder.get(a.storyline_id) ?? 99) - (idOrder.get(b.storyline_id) ?? 99), - ); - } + 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); + } - case "rising": { - // Acceleration: more donations in last 3 days vs prior 3 days + // Compare trading activity (donations as proxy) in last 3 days vs prior 3 days const now = Date.now(); const recentSince = new Date(now - 3 * 24 * 60 * 60 * 1000).toISOString(); const priorSince = new Date(now - 6 * 24 * 60 * 60 * 1000).toISOString(); @@ -166,7 +179,6 @@ async function queryTab( .lt("block_timestamp", recentSince) .returns[]>(); - // Count per storyline in each period const recentCounts = new Map(); for (const d of recentDonations ?? []) { recentCounts.set(d.storyline_id, (recentCounts.get(d.storyline_id) ?? 0) + 1); @@ -177,42 +189,26 @@ async function queryTab( priorCounts.set(d.storyline_id, (priorCounts.get(d.storyline_id) ?? 0) + 1); } - // Compute acceleration: recent - prior (only positive acceleration) - const accelerating: { storyline_id: number; accel: number }[] = []; - for (const [id, recent] of recentCounts) { - const prior = priorCounts.get(id) ?? 0; + // Score by acceleration: recent - prior + const accelerating: { storyline: Storyline; accel: number }[] = []; + for (const s of withTokens) { + const recent = recentCounts.get(s.storyline_id) ?? 0; + const prior = priorCounts.get(s.storyline_id) ?? 0; const accel = recent - prior; - if (accel > 0) { - accelerating.push({ storyline_id: id, accel }); + if (accel > 0 || (recent > 0 && prior === 0)) { + accelerating.push({ storyline: s, accel: accel > 0 ? accel : recent }); } } - accelerating.sort((a, b) => b.accel - a.accel); - - const topIds = accelerating.slice(0, 50).map((r) => r.storyline_id); - if (topIds.length === 0) { - const { data } = await supabase - .from("storylines") - .select("*") - .eq("hidden", false) - .eq("sunset", false) - .order("block_timestamp", { ascending: false }) - .limit(50) - .returns(); - return data ?? []; + if (accelerating.length === 0) { + // Fallback: newest with tokens + return withTokens + .sort((a, b) => (b.block_timestamp ?? "").localeCompare(a.block_timestamp ?? "")) + .slice(0, 50); } - const { data: storylines } = await supabase - .from("storylines") - .select("*") - .eq("hidden", false) - .in("storyline_id", topIds) - .returns(); - - const idOrder = new Map(topIds.map((id, i) => [id, i])); - return (storylines ?? []).sort( - (a, b) => (idOrder.get(a.storyline_id) ?? 99) - (idOrder.get(b.storyline_id) ?? 99), - ); + accelerating.sort((a, b) => b.accel - a.accel); + return accelerating.slice(0, 50).map((a) => a.storyline); } } } From 224826c18a1c18023883763f0e2917d87779aeb1 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:17:09 +0000 Subject: [PATCH 3/4] [#28] Use purely on-chain trading signals, drop donation proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trending: ranks by on-chain totalSupply (minted volume = trade activity) Rising: ranks by supply growth rate (totalSupply / age in hours) to surface newer stories with accelerating minting activity. No donation data used — all signals are genuine on-chain trade data. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/discover/page.tsx | 107 ++++++++++++-------------------------- 1 file changed, 33 insertions(+), 74 deletions(-) diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx index 78148ef8..f9285cdf 100644 --- a/src/app/discover/page.tsx +++ b/src/app/discover/page.tsx @@ -1,4 +1,4 @@ -import { createServerClient, type Storyline, type Donation } from "../../../lib/supabase"; +import { createServerClient, type Storyline } from "../../../lib/supabase"; import { StoryCard } from "../../components/StoryCard"; import { TabNav } from "../../components/TabNav"; import { publicClient } from "../../../lib/rpc"; @@ -95,7 +95,7 @@ async function queryTab( } case "trending": { - // Fetch active storylines with token addresses + // Rank by on-chain totalSupply (minted token volume = trading activity) const { data: allStorylines } = await supabase .from("storylines") .select("*") @@ -111,41 +111,26 @@ async function queryTab( .slice(0, 50); } - // Read on-chain totalSupply (trading volume proxy) for each token + // Read on-chain totalSupply for each storyline token const supplies = await Promise.all( withTokens.map((s) => readSupply(s.token_address)), ); - // Fetch recent unique buyers (donation donors as buyer proxy) in last 7 days - const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - const { data: donations } = await supabase - .from("donations") - .select("storyline_id, donor_address") - .gte("block_timestamp", since) - .returns[]>(); - - const donorMap = new Map>(); - for (const d of donations ?? []) { - const set = donorMap.get(d.storyline_id) ?? new Set(); - set.add(d.donor_address); - donorMap.set(d.storyline_id, set); - } + // Rank by totalSupply descending (higher supply = more trading activity) + const scored = withTokens + .map((s, i) => ({ storyline: s, supply: supplies[i] })) + .filter((s) => s.supply > BigInt(0)); - // Composite score: normalized supply + 2 * unique buyers - const maxSupply = supplies.reduce((a, b) => (a > b ? a : b), BigInt(1)); - const scored = withTokens.map((s, i) => ({ - storyline: s, - score: - Number((supplies[i] * BigInt(100)) / maxSupply) + - 2 * (donorMap.get(s.storyline_id)?.size ?? 0), - })); + scored.sort((a, b) => + a.supply > b.supply ? -1 : a.supply < b.supply ? 1 : 0, + ); - scored.sort((a, b) => b.score - a.score); return scored.slice(0, 50).map((s) => s.storyline); } case "rising": { - // Fetch active storylines with tokens + // Rank by supply growth rate: totalSupply / age (newer stories with high + // supply are rising faster than older ones with the same supply) const { data: allStorylines } = await supabase .from("storylines") .select("*") @@ -161,54 +146,28 @@ async function queryTab( .slice(0, 50); } - // Compare trading activity (donations as proxy) in last 3 days vs prior 3 days - const now = Date.now(); - const recentSince = new Date(now - 3 * 24 * 60 * 60 * 1000).toISOString(); - const priorSince = new Date(now - 6 * 24 * 60 * 60 * 1000).toISOString(); - - const { data: recentDonations } = await supabase - .from("donations") - .select("storyline_id") - .gte("block_timestamp", recentSince) - .returns[]>(); - - const { data: priorDonations } = await supabase - .from("donations") - .select("storyline_id") - .gte("block_timestamp", priorSince) - .lt("block_timestamp", recentSince) - .returns[]>(); - - const recentCounts = new Map(); - for (const d of recentDonations ?? []) { - recentCounts.set(d.storyline_id, (recentCounts.get(d.storyline_id) ?? 0) + 1); - } - - const priorCounts = new Map(); - for (const d of priorDonations ?? []) { - priorCounts.set(d.storyline_id, (priorCounts.get(d.storyline_id) ?? 0) + 1); - } - - // Score by acceleration: recent - prior - const accelerating: { storyline: Storyline; accel: number }[] = []; - for (const s of withTokens) { - const recent = recentCounts.get(s.storyline_id) ?? 0; - const prior = priorCounts.get(s.storyline_id) ?? 0; - const accel = recent - prior; - if (accel > 0 || (recent > 0 && prior === 0)) { - accelerating.push({ storyline: s, accel: accel > 0 ? accel : recent }); - } - } - - if (accelerating.length === 0) { - // Fallback: newest with tokens - return withTokens - .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)), + ); - accelerating.sort((a, b) => b.accel - a.accel); - return accelerating.slice(0, 50).map((a) => a.storyline); + const now = Date.now(); + const scored = withTokens + .map((s, i) => { + const supply = supplies[i]; + if (supply === BigInt(0)) return null; + // Age in hours (min 1 to avoid division by zero) + const ageMs = s.block_timestamp + ? now - new Date(s.block_timestamp).getTime() + : now; + const ageHours = Math.max(ageMs / (1000 * 60 * 60), 1); + // Growth rate = supply per hour (normalized) + const rate = Number(supply) / ageHours; + return { storyline: s, rate }; + }) + .filter((s): s is { storyline: Storyline; rate: number } => s !== null); + + scored.sort((a, b) => b.rate - a.rate); + return scored.slice(0, 50).map((s) => s.storyline); } } } From eb4fa114720a895752aab594cf71a01c89e207ba Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 14 Mar 2026 20:18:54 +0000 Subject: [PATCH 4/4] [#28] Composite trending, explicit rising fallback Trending: composite of totalSupply + plot_count + recency bonus. Rising: explicit fallback to recently active stories with supply, with notice that full acceleration ranking requires a trades indexer. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/discover/page.tsx | 74 +++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx index f9285cdf..f9160d30 100644 --- a/src/app/discover/page.tsx +++ b/src/app/discover/page.tsx @@ -38,6 +38,12 @@ export default async function DiscoverPage({ + {tab === "rising" && ( +

+ Acceleration ranking requires a trades indexer — showing recent active stories. +

+ )} +
{storylines.map((s) => ( @@ -95,7 +101,12 @@ async function queryTab( } case "trending": { - // Rank by on-chain totalSupply (minted token volume = trading activity) + // 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("*") @@ -111,63 +122,66 @@ async function queryTab( .slice(0, 50); } - // Read on-chain totalSupply for each storyline token const supplies = await Promise.all( withTokens.map((s) => readSupply(s.token_address)), ); - // Rank by totalSupply descending (higher supply = more trading activity) + const maxSupply = supplies.reduce((a, b) => (a > b ? a : b), BigInt(1)); + const now = Date.now(); + const scored = withTokens - .map((s, i) => ({ storyline: s, supply: supplies[i] })) - .filter((s) => s.supply > BigInt(0)); + .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; - scored.sort((a, b) => - a.supply > b.supply ? -1 : a.supply < b.supply ? 1 : 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); } case "rising": { - // Rank by supply growth rate: totalSupply / age (newer stories with high - // supply are rising faster than older ones with the same supply) + // 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 }) .returns(); 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); + 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 now = Date.now(); - const scored = withTokens - .map((s, i) => { - const supply = supplies[i]; - if (supply === BigInt(0)) return null; - // Age in hours (min 1 to avoid division by zero) - const ageMs = s.block_timestamp - ? now - new Date(s.block_timestamp).getTime() - : now; - const ageHours = Math.max(ageMs / (1000 * 60 * 60), 1); - // Growth rate = supply per hour (normalized) - const rate = Number(supply) / ageHours; - return { storyline: s, rate }; - }) - .filter((s): s is { storyline: Storyline; rate: number } => s !== null); + const active = withTokens.filter((_, i) => supplies[i] > BigInt(0)); + if (active.length === 0) { + return storylines.slice(0, 50); + } - scored.sort((a, b) => b.rate - a.rate); - return scored.slice(0, 50).map((s) => s.storyline); + return active.slice(0, 50); } } }