From be3816455a05c6ab0271be5b4f0bce7811152c91 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 08:11:50 +0000 Subject: [PATCH 1/3] [#28] Implement trending and rising discovery tabs Trending: composite ranking using 4 signals: - Average reader rating (from ratings table) - 24h price change % (on-chain via priceForNextMint block diff) - TVL / reserveBalance (on-chain via tokenBond) - Plot continuation rate (plots per day) Rising: acceleration-based ranking: - Recent plot activity (last 3 days) vs prior window (days 3-6) - Positive 24h price change as momentum boost Replaces placeholder queries from P4-2 with real composite ranking. Fixes #28 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/ranking.ts | 188 ++++++++++++++++++++++++++++++++++++++ src/app/discover/page.tsx | 33 +------ 2 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 lib/ranking.ts diff --git a/lib/ranking.ts b/lib/ranking.ts new file mode 100644 index 00000000..c847873b --- /dev/null +++ b/lib/ranking.ts @@ -0,0 +1,188 @@ +import { type Address, formatUnits } from "viem"; +import { get24hPriceChange, getTokenTVL } from "./price"; +import type { Storyline } from "./supabase"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +interface RankedStoryline extends Storyline { + trendScore: number; +} + +/** + * Compute trending score for a storyline. + * + * Composite of 4 signals (each normalized to ~0-1 range): + * - avgRating: average reader rating (0-5 → 0-1) + * - priceChange24h: 24h price change % (clamped, mapped to 0-1) + * - tvl: reserve balance (log-scaled) + * - continuationRate: plots per day since creation + */ +function computeTrendScore( + avgRating: number, + priceChange: number | null, + tvlRaw: bigint | null, + plotCount: number, + createdAt: string | null, +): number { + // Rating signal (0-1), weight: 0.3 + const ratingSignal = avgRating / 5; + + // Price change signal (0-1), weight: 0.25 + // Clamp between -100% and +200%, map to 0-1 + const pc = priceChange ?? 0; + const clampedPc = Math.max(-100, Math.min(200, pc)); + const priceSignal = (clampedPc + 100) / 300; + + // TVL signal (0-1), weight: 0.25 + // Log-scale: log(1 + tvl_in_units) + let tvlSignal = 0; + if (tvlRaw !== null && tvlRaw > BigInt(0)) { + const tvlFloat = Number(formatUnits(tvlRaw, 18)); + tvlSignal = Math.min(1, Math.log10(1 + tvlFloat) / 3); + } + + // Continuation rate signal (0-1), weight: 0.2 + // plots per day, capped at 5/day + let contSignal = 0; + if (createdAt && plotCount > 1) { + const ageMs = Date.now() - new Date(createdAt).getTime(); + const ageDays = Math.max(1, ageMs / (1000 * 60 * 60 * 24)); + contSignal = Math.min(1, (plotCount / ageDays) / 5); + } + + return ( + ratingSignal * 0.3 + + priceSignal * 0.25 + + tvlSignal * 0.25 + + contSignal * 0.2 + ); +} + +/** + * Fetch trending storylines ranked by composite score. + * Fetches candidates from Supabase, enriches with on-chain data. + */ +export async function getTrendingStorylines( + supabase: SupabaseClient, + limit = 20, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data } = await (supabase.from("storylines") as any) + .select("*") + .eq("hidden", false) + .eq("sunset", false) + .neq("token_address", "") + .order("block_timestamp", { ascending: false }) + .limit(50); + + const storylines = (data ?? []) as Storyline[]; + if (storylines.length === 0) return []; + + // Fetch ratings per storyline + const ratingMap = new Map(); + for (const sl of storylines) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: rData } = await (supabase.from("ratings") as any) + .select("rating") + .eq("storyline_id", sl.storyline_id); + const rows = (rData ?? []) as { rating: number }[]; + if (rows.length > 0) { + const avg = rows.reduce((s, r) => s + r.rating, 0) / rows.length; + ratingMap.set(sl.storyline_id, avg); + } + } + + // Enrich with on-chain data (parallel, error-tolerant) + const enriched = await Promise.all( + storylines.map(async (sl): Promise => { + const tokenAddr = sl.token_address as Address; + const [priceChangeResult, tvlResult] = await Promise.all([ + get24hPriceChange(tokenAddr).catch(() => null), + getTokenTVL(tokenAddr).catch(() => null), + ]); + + const avgRating = ratingMap.get(sl.storyline_id) ?? 0; + const priceChange = priceChangeResult?.changePercent ?? null; + const tvlRaw = tvlResult?.tvlRaw ?? null; + + const trendScore = computeTrendScore( + avgRating, + priceChange, + tvlRaw, + sl.plot_count, + sl.block_timestamp, + ); + + return { ...sl, trendScore }; + }), + ); + + enriched.sort((a, b) => b.trendScore - a.trendScore); + return enriched.slice(0, limit); +} + +/** + * Fetch rising storylines — stories with accelerating activity. + * + * Compares recent activity (plots in last 3 days) vs prior period (days 3-6). + * Stories with higher recent activity relative to baseline are "rising". + * Also factors in positive 24h price change for on-chain momentum. + */ +export async function getRisingStorylines( + supabase: SupabaseClient, + limit = 20, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data } = await (supabase.from("storylines") as any) + .select("*") + .eq("hidden", false) + .eq("sunset", false) + .neq("token_address", "") + .order("block_timestamp", { ascending: false }) + .limit(50); + + const storylines = (data ?? []) as Storyline[]; + if (storylines.length === 0) return []; + + const now = new Date(); + const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(); + const sixDaysAgo = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000).toISOString(); + + const enriched = await Promise.all( + storylines.map(async (sl): Promise => { + // Recent plots (last 3 days) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { count: recentPlots } = await (supabase.from("plots") as any) + .select("*", { count: "exact", head: true }) + .eq("storyline_id", sl.storyline_id) + .gte("block_timestamp", threeDaysAgo); + + // Prior plots (days 3-6) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { count: priorPlots } = await (supabase.from("plots") as any) + .select("*", { count: "exact", head: true }) + .eq("storyline_id", sl.storyline_id) + .gte("block_timestamp", sixDaysAgo) + .lt("block_timestamp", threeDaysAgo); + + const recent = recentPlots ?? 0; + const prior = priorPlots ?? 0; + + // Acceleration: recent activity vs prior baseline + const acceleration = recent / (prior + 1); + + // Factor in 24h price change for on-chain momentum + const tokenAddr = sl.token_address as Address; + const priceChangeResult = await get24hPriceChange(tokenAddr).catch(() => null); + const priceBoost = priceChangeResult + ? Math.max(0, priceChangeResult.changePercent) / 100 + : 0; + + const trendScore = acceleration * 0.7 + priceBoost * 0.3; + + return { ...sl, trendScore }; + }), + ); + + enriched.sort((a, b) => b.trendScore - a.trendScore); + return enriched.filter((s) => s.trendScore > 0).slice(0, limit); +} diff --git a/src/app/discover/page.tsx b/src/app/discover/page.tsx index f742e8af..57e3fec1 100644 --- a/src/app/discover/page.tsx +++ b/src/app/discover/page.tsx @@ -1,4 +1,5 @@ import { createServerClient, type Storyline } from "../../../lib/supabase"; +import { getTrendingStorylines, getRisingStorylines } from "../../../lib/ranking"; import { StoryCard } from "../../components/StoryCard"; import { TabNav } from "../../components/TabNav"; @@ -35,12 +36,6 @@ export default async function DiscoverPage({ - {(tab === "trending" || tab === "rising") && ( -

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

- )} -
{storylines.map((s) => ( @@ -84,34 +79,12 @@ 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 - .from("storylines") - .select("*") - .eq("hidden", false) - .eq("sunset", false) - .order("block_timestamp", { ascending: false }) - .limit(50) - .returns(); - return data ?? []; + return getTrendingStorylines(supabase, 20); } - // 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 - .from("storylines") - .select("*") - .eq("hidden", false) - .eq("sunset", false) - .order("block_timestamp", { ascending: false }) - .limit(50) - .returns(); - return data ?? []; + return getRisingStorylines(supabase, 20); } } } From 4d9a3f5054c7de4cb111213d1975611639abe3d0 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 08:15:12 +0000 Subject: [PATCH 2/3] [#28] Fix review feedback: Rising uses 4 signals across windows, batch ratings, TVL decimals - Rising now computes full composite score (rating, price, TVL, plots) in recent window vs prior window, uses ratio as acceleration - Batch ratings query instead of N+1 per-storyline queries - computeTrendScore uses actual reserve token decimals from getTokenTVL() Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/ranking.ts | 169 +++++++++++++++++++++++++++++++------------------ 1 file changed, 108 insertions(+), 61 deletions(-) diff --git a/lib/ranking.ts b/lib/ranking.ts index c847873b..edb3f874 100644 --- a/lib/ranking.ts +++ b/lib/ranking.ts @@ -13,13 +13,14 @@ interface RankedStoryline extends Storyline { * Composite of 4 signals (each normalized to ~0-1 range): * - avgRating: average reader rating (0-5 → 0-1) * - priceChange24h: 24h price change % (clamped, mapped to 0-1) - * - tvl: reserve balance (log-scaled) + * - tvl: reserve balance (log-scaled, using actual token decimals) * - continuationRate: plots per day since creation */ function computeTrendScore( avgRating: number, priceChange: number | null, tvlRaw: bigint | null, + tvlDecimals: number, plotCount: number, createdAt: string | null, ): number { @@ -27,21 +28,18 @@ function computeTrendScore( const ratingSignal = avgRating / 5; // Price change signal (0-1), weight: 0.25 - // Clamp between -100% and +200%, map to 0-1 const pc = priceChange ?? 0; const clampedPc = Math.max(-100, Math.min(200, pc)); const priceSignal = (clampedPc + 100) / 300; // TVL signal (0-1), weight: 0.25 - // Log-scale: log(1 + tvl_in_units) let tvlSignal = 0; if (tvlRaw !== null && tvlRaw > BigInt(0)) { - const tvlFloat = Number(formatUnits(tvlRaw, 18)); + const tvlFloat = Number(formatUnits(tvlRaw, tvlDecimals)); tvlSignal = Math.min(1, Math.log10(1 + tvlFloat) / 3); } // Continuation rate signal (0-1), weight: 0.2 - // plots per day, capped at 5/day let contSignal = 0; if (createdAt && plotCount > 1) { const ageMs = Date.now() - new Date(createdAt).getTime(); @@ -57,14 +55,8 @@ function computeTrendScore( ); } -/** - * Fetch trending storylines ranked by composite score. - * Fetches candidates from Supabase, enriches with on-chain data. - */ -export async function getTrendingStorylines( - supabase: SupabaseClient, - limit = 20, -): Promise { +/** Shared: fetch storyline candidates + batch ratings */ +async function fetchCandidatesAndRatings(supabase: SupabaseClient) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data } = await (supabase.from("storylines") as any) .select("*") @@ -75,39 +67,68 @@ export async function getTrendingStorylines( .limit(50); const storylines = (data ?? []) as Storyline[]; - if (storylines.length === 0) return []; + if (storylines.length === 0) return { storylines, ratingMap: new Map() }; + + // Batch: fetch all ratings for candidate storyline IDs in one query + const storylineIds = storylines.map((sl) => sl.storyline_id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: allRatings } = await (supabase.from("ratings") as any) + .select("storyline_id, rating") + .in("storyline_id", storylineIds); - // Fetch ratings per storyline const ratingMap = new Map(); - for (const sl of storylines) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data: rData } = await (supabase.from("ratings") as any) - .select("rating") - .eq("storyline_id", sl.storyline_id); - const rows = (rData ?? []) as { rating: number }[]; - if (rows.length > 0) { - const avg = rows.reduce((s, r) => s + r.rating, 0) / rows.length; - ratingMap.set(sl.storyline_id, avg); + if (allRatings) { + const grouped = new Map(); + for (const r of allRatings as { storyline_id: number; rating: number }[]) { + const arr = grouped.get(r.storyline_id) ?? []; + arr.push(r.rating); + grouped.set(r.storyline_id, arr); + } + for (const [id, ratings] of grouped) { + ratingMap.set(id, ratings.reduce((s, v) => s + v, 0) / ratings.length); } } - // Enrich with on-chain data (parallel, error-tolerant) + return { storylines, ratingMap }; +} + +/** Shared: enrich a storyline with on-chain signals */ +async function enrichWithOnChain( + sl: Storyline, +): Promise<{ priceChange: number | null; tvlRaw: bigint | null; tvlDecimals: number }> { + const tokenAddr = sl.token_address as Address; + const [priceChangeResult, tvlResult] = await Promise.all([ + get24hPriceChange(tokenAddr).catch(() => null), + getTokenTVL(tokenAddr).catch(() => null), + ]); + + return { + priceChange: priceChangeResult?.changePercent ?? null, + tvlRaw: tvlResult?.tvlRaw ?? null, + tvlDecimals: tvlResult?.decimals ?? 18, + }; +} + +/** + * Fetch trending storylines ranked by composite score. + */ +export async function getTrendingStorylines( + supabase: SupabaseClient, + limit = 20, +): Promise { + const { storylines, ratingMap } = await fetchCandidatesAndRatings(supabase); + if (storylines.length === 0) return []; + const enriched = await Promise.all( storylines.map(async (sl): Promise => { - const tokenAddr = sl.token_address as Address; - const [priceChangeResult, tvlResult] = await Promise.all([ - get24hPriceChange(tokenAddr).catch(() => null), - getTokenTVL(tokenAddr).catch(() => null), - ]); - const avgRating = ratingMap.get(sl.storyline_id) ?? 0; - const priceChange = priceChangeResult?.changePercent ?? null; - const tvlRaw = tvlResult?.tvlRaw ?? null; + const { priceChange, tvlRaw, tvlDecimals } = await enrichWithOnChain(sl); const trendScore = computeTrendScore( avgRating, priceChange, tvlRaw, + tvlDecimals, sl.plot_count, sl.block_timestamp, ); @@ -121,68 +142,94 @@ export async function getTrendingStorylines( } /** - * Fetch rising storylines — stories with accelerating activity. + * Fetch rising storylines — stories with accelerating signals. * - * Compares recent activity (plots in last 3 days) vs prior period (days 3-6). - * Stories with higher recent activity relative to baseline are "rising". - * Also factors in positive 24h price change for on-chain momentum. + * Computes the same 4 signals in a recent window (last 3 days) vs + * prior window (days 3-6). The "rise" score is the ratio of recent + * composite score to prior composite score. */ export async function getRisingStorylines( supabase: SupabaseClient, limit = 20, ): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data } = await (supabase.from("storylines") as any) - .select("*") - .eq("hidden", false) - .eq("sunset", false) - .neq("token_address", "") - .order("block_timestamp", { ascending: false }) - .limit(50); - - const storylines = (data ?? []) as Storyline[]; + const { storylines } = await fetchCandidatesAndRatings(supabase); if (storylines.length === 0) return []; const now = new Date(); const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(); const sixDaysAgo = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000).toISOString(); + // Batch: fetch recent and prior ratings for all candidates + const storylineIds = storylines.map((sl) => sl.storyline_id); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: recentRatings } = await (supabase.from("ratings") as any) + .select("storyline_id, rating") + .in("storyline_id", storylineIds) + .gte("updated_at", threeDaysAgo); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: priorRatings } = await (supabase.from("ratings") as any) + .select("storyline_id, rating") + .in("storyline_id", storylineIds) + .gte("updated_at", sixDaysAgo) + .lt("updated_at", threeDaysAgo); + + function avgFromRows(rows: { storyline_id: number; rating: number }[] | null, slId: number): number { + if (!rows) return 0; + const filtered = rows.filter((r) => r.storyline_id === slId); + if (filtered.length === 0) return 0; + return filtered.reduce((s, r) => s + r.rating, 0) / filtered.length; + } + const enriched = await Promise.all( storylines.map(async (sl): Promise => { // Recent plots (last 3 days) // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { count: recentPlots } = await (supabase.from("plots") as any) + const { count: recentPlotCount } = await (supabase.from("plots") as any) .select("*", { count: "exact", head: true }) .eq("storyline_id", sl.storyline_id) .gte("block_timestamp", threeDaysAgo); // Prior plots (days 3-6) // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { count: priorPlots } = await (supabase.from("plots") as any) + const { count: priorPlotCount } = await (supabase.from("plots") as any) .select("*", { count: "exact", head: true }) .eq("storyline_id", sl.storyline_id) .gte("block_timestamp", sixDaysAgo) .lt("block_timestamp", threeDaysAgo); - const recent = recentPlots ?? 0; - const prior = priorPlots ?? 0; + const { priceChange, tvlRaw, tvlDecimals } = await enrichWithOnChain(sl); - // Acceleration: recent activity vs prior baseline - const acceleration = recent / (prior + 1); + // Recent window composite + const recentAvgRating = avgFromRows(recentRatings as { storyline_id: number; rating: number }[] | null, sl.storyline_id); + const recentScore = computeTrendScore( + recentAvgRating, + priceChange, // current price change (recent by nature) + tvlRaw, + tvlDecimals, + recentPlotCount ?? 0, + threeDaysAgo, + ); - // Factor in 24h price change for on-chain momentum - const tokenAddr = sl.token_address as Address; - const priceChangeResult = await get24hPriceChange(tokenAddr).catch(() => null); - const priceBoost = priceChangeResult - ? Math.max(0, priceChangeResult.changePercent) / 100 - : 0; + // Prior window composite + const priorAvgRating = avgFromRows(priorRatings as { storyline_id: number; rating: number }[] | null, sl.storyline_id); + const priorScore = computeTrendScore( + priorAvgRating, + 0, // no price change data for prior window + null, // no historical TVL + tvlDecimals, + priorPlotCount ?? 0, + sixDaysAgo, + ); - const trendScore = acceleration * 0.7 + priceBoost * 0.3; + // Rise = recent / prior (acceleration ratio) + const trendScore = recentScore / (priorScore + 0.01); return { ...sl, trendScore }; }), ); enriched.sort((a, b) => b.trendScore - a.trendScore); - return enriched.filter((s) => s.trendScore > 0).slice(0, limit); + return enriched.filter((s) => s.trendScore > 1).slice(0, limit); } From 7b8cf7293aa3ce7cc3ae68f3d18a33e982e435c8 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 08:16:52 +0000 Subject: [PATCH 3/3] [#28] Fix Rising to use same 4 signals across windows + eliminate N+1 - Prior window now uses same TVL + price as baseline (point-in-time values; acceleration comes from rating/plot signal differences) - Batched plot count queries via .in() instead of per-storyline N+1 - On-chain reads batched in single Promise.all across all storylines Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/ranking.ts | 107 +++++++++++++++++++++++++++---------------------- 1 file changed, 59 insertions(+), 48 deletions(-) diff --git a/lib/ranking.ts b/lib/ranking.ts index edb3f874..22e7f79b 100644 --- a/lib/ranking.ts +++ b/lib/ranking.ts @@ -145,8 +145,10 @@ export async function getTrendingStorylines( * Fetch rising storylines — stories with accelerating signals. * * Computes the same 4 signals in a recent window (last 3 days) vs - * prior window (days 3-6). The "rise" score is the ratio of recent - * composite score to prior composite score. + * prior window (days 3-6). TVL is point-in-time (same for both windows, + * as historical TVL requires snapshots). Price change is inherently + * recent (24h lookback), so prior window uses the same value as a + * baseline denominator — acceleration comes from rating + plot signals. */ export async function getRisingStorylines( supabase: SupabaseClient, @@ -159,9 +161,9 @@ export async function getRisingStorylines( const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(); const sixDaysAgo = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000).toISOString(); - // Batch: fetch recent and prior ratings for all candidates const storylineIds = storylines.map((sl) => sl.storyline_id); + // Batch: windowed ratings // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: recentRatings } = await (supabase.from("ratings") as any) .select("storyline_id, rating") @@ -175,6 +177,20 @@ export async function getRisingStorylines( .gte("updated_at", sixDaysAgo) .lt("updated_at", threeDaysAgo); + // Batch: windowed plot counts + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: recentPlots } = await (supabase.from("plots") as any) + .select("storyline_id") + .in("storyline_id", storylineIds) + .gte("block_timestamp", threeDaysAgo); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: priorPlots } = await (supabase.from("plots") as any) + .select("storyline_id") + .in("storyline_id", storylineIds) + .gte("block_timestamp", sixDaysAgo) + .lt("block_timestamp", threeDaysAgo); + function avgFromRows(rows: { storyline_id: number; rating: number }[] | null, slId: number): number { if (!rows) return 0; const filtered = rows.filter((r) => r.storyline_id === slId); @@ -182,54 +198,49 @@ export async function getRisingStorylines( return filtered.reduce((s, r) => s + r.rating, 0) / filtered.length; } - const enriched = await Promise.all( - storylines.map(async (sl): Promise => { - // Recent plots (last 3 days) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { count: recentPlotCount } = await (supabase.from("plots") as any) - .select("*", { count: "exact", head: true }) - .eq("storyline_id", sl.storyline_id) - .gte("block_timestamp", threeDaysAgo); - - // Prior plots (days 3-6) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { count: priorPlotCount } = await (supabase.from("plots") as any) - .select("*", { count: "exact", head: true }) - .eq("storyline_id", sl.storyline_id) - .gte("block_timestamp", sixDaysAgo) - .lt("block_timestamp", threeDaysAgo); - - const { priceChange, tvlRaw, tvlDecimals } = await enrichWithOnChain(sl); - - // Recent window composite - const recentAvgRating = avgFromRows(recentRatings as { storyline_id: number; rating: number }[] | null, sl.storyline_id); - const recentScore = computeTrendScore( - recentAvgRating, - priceChange, // current price change (recent by nature) - tvlRaw, - tvlDecimals, - recentPlotCount ?? 0, - threeDaysAgo, - ); - - // Prior window composite - const priorAvgRating = avgFromRows(priorRatings as { storyline_id: number; rating: number }[] | null, sl.storyline_id); - const priorScore = computeTrendScore( - priorAvgRating, - 0, // no price change data for prior window - null, // no historical TVL - tvlDecimals, - priorPlotCount ?? 0, - sixDaysAgo, - ); - - // Rise = recent / prior (acceleration ratio) - const trendScore = recentScore / (priorScore + 0.01); + function countFromRows(rows: { storyline_id: number }[] | null, slId: number): number { + if (!rows) return 0; + return rows.filter((r) => r.storyline_id === slId).length; + } - return { ...sl, trendScore }; - }), + // Single parallel batch for all on-chain reads + const onChainResults = await Promise.all( + storylines.map((sl) => enrichWithOnChain(sl)), ); + const enriched = storylines.map((sl, i): RankedStoryline => { + const { priceChange, tvlRaw, tvlDecimals } = onChainResults[i]; + + // Recent window composite (all 4 signals) + const recentAvgRating = avgFromRows(recentRatings as { storyline_id: number; rating: number }[] | null, sl.storyline_id); + const recentPlotCount = countFromRows(recentPlots as { storyline_id: number }[] | null, sl.storyline_id); + const recentScore = computeTrendScore( + recentAvgRating, + priceChange, + tvlRaw, + tvlDecimals, + recentPlotCount, + threeDaysAgo, + ); + + // Prior window composite (same 4 signals, same TVL + price as baseline) + const priorAvgRating = avgFromRows(priorRatings as { storyline_id: number; rating: number }[] | null, sl.storyline_id); + const priorPlotCount = countFromRows(priorPlots as { storyline_id: number }[] | null, sl.storyline_id); + const priorScore = computeTrendScore( + priorAvgRating, + priceChange, // same baseline — acceleration from rating/plot signals + tvlRaw, // point-in-time, same for both windows + tvlDecimals, + priorPlotCount, + sixDaysAgo, + ); + + // Rise = recent / prior (acceleration ratio) + const trendScore = recentScore / (priorScore + 0.01); + + return { ...sl, trendScore }; + }); + enriched.sort((a, b) => b.trendScore - a.trendScore); return enriched.filter((s) => s.trendScore > 1).slice(0, limit); }