From 2b64ae64055b8eff7f5ffaeb47c7aea8e1101e1f Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 11:55:41 +0000 Subject: [PATCH 1/2] [#560] Add recency boost, Bayesian ratings, default trending sort - Add recency signal (20% weight) using last_plot_time with inverse-time decay so recently updated stories rank higher - Replace raw average rating with Bayesian weighted rating (prior_count=5, prior_mean=3.0) so stories need multiple good ratings to rank high - Redistribute weights: rating 25%, price 20%, TVL 20%, continuation 15%, recency 20% - Change default homepage sort tab from "new" to "trending" Fixes #560 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/ranking.ts | 58 ++++++++++++++++++++++++++++++++++-------------- src/app/page.tsx | 2 +- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/lib/ranking.ts b/lib/ranking.ts index aa1737e5..c817db3a 100644 --- a/lib/ranking.ts +++ b/lib/ranking.ts @@ -11,36 +11,44 @@ interface RankedStoryline extends Storyline { /** * Compute trending score for a storyline. * - * Composite of 4 signals (each normalized to ~0-1 range): - * - avgRating: average reader rating (0-5 → 0-1) + * Composite of 5 signals (each normalized to ~0-1 range): + * - weightedRating: Bayesian rating accounting for rating count (0-5 → 0-1) * - priceChange24h: 24h price change % (clamped, mapped to 0-1) * - tvl: reserve balance (log-scaled, using actual token decimals) * - continuationRate: plots per day since creation + * - recency: boost for recently updated stories based on last_plot_time */ function computeTrendScore( avgRating: number, + ratingCount: number, priceChange: number | null, tvlRaw: bigint | null, tvlDecimals: number, plotCount: number, createdAt: string | null, + lastPlotTime: string | null, ): number { - // Rating signal (0-1), weight: 0.3 - const ratingSignal = avgRating / 5; - - // Price change signal (0-1), weight: 0.25 + // Bayesian weighted rating signal (0-1), weight: 0.25 + const priorCount = 5; + const priorMean = 3.0; + const weightedRating = + (ratingCount * avgRating + priorCount * priorMean) / + (ratingCount + priorCount); + const ratingSignal = weightedRating / 5; + + // Price change signal (0-1), weight: 0.20 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 + // TVL signal (0-1), weight: 0.20 let tvlSignal = 0; if (tvlRaw !== null && tvlRaw > BigInt(0)) { const tvlFloat = Number(formatUnits(tvlRaw, tvlDecimals)); tvlSignal = Math.min(1, Math.log10(1 + tvlFloat) / 3); } - // Continuation rate signal (0-1), weight: 0.2 + // Continuation rate signal (0-1), weight: 0.15 let contSignal = 0; if (createdAt && plotCount > 1) { const ageMs = Date.now() - new Date(createdAt).getTime(); @@ -48,11 +56,22 @@ function computeTrendScore( contSignal = Math.min(1, (plotCount / ageDays) / 5); } + // Recency signal (0-1), weight: 0.20 + // Uses last_plot_time (falls back to createdAt) with inverse-time decay + const recencyRef = lastPlotTime ?? createdAt; + let recencySignal = 0; + if (recencyRef) { + const daysSince = + (Date.now() - new Date(recencyRef).getTime()) / (1000 * 60 * 60 * 24); + recencySignal = 1 / (1 + Math.max(0, daysSince)); + } + return ( - ratingSignal * 0.3 + - priceSignal * 0.25 + - tvlSignal * 0.25 + - contSignal * 0.2 + ratingSignal * 0.25 + + priceSignal * 0.2 + + tvlSignal * 0.2 + + contSignal * 0.15 + + recencySignal * 0.2 ); } @@ -77,7 +96,7 @@ async function fetchCandidatesAndRatings( .limit(50); const storylines = (data ?? []) as Storyline[]; - if (storylines.length === 0) return { storylines, ratingMap: new Map() }; + 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); @@ -86,7 +105,7 @@ async function fetchCandidatesAndRatings( .in("storyline_id", storylineIds) .eq("contract_address", STORY_FACTORY.toLowerCase()); - const ratingMap = new Map(); + const ratingMap = new Map(); if (allRatings) { const grouped = new Map(); for (const r of allRatings) { @@ -95,7 +114,10 @@ async function fetchCandidatesAndRatings( grouped.set(r.storyline_id, arr); } for (const [id, ratings] of grouped) { - ratingMap.set(id, ratings.reduce((s, v) => s + v, 0) / ratings.length); + ratingMap.set(id, { + avg: ratings.reduce((s, v) => s + v, 0) / ratings.length, + count: ratings.length, + }); } } @@ -135,16 +157,18 @@ export async function getTrendingStorylines( const enriched = await Promise.all( storylines.map(async (sl): Promise => { - const avgRating = ratingMap.get(sl.storyline_id) ?? 0; + const rating = ratingMap.get(sl.storyline_id) ?? { avg: 0, count: 0 }; const { priceChange, tvlRaw, tvlDecimals } = await enrichWithOnChain(sl); const trendScore = computeTrendScore( - avgRating, + rating.avg, + rating.count, priceChange, tvlRaw, tvlDecimals, sl.plot_count, sl.block_timestamp, + sl.last_plot_time, ); return { ...sl, trendScore }; diff --git a/src/app/page.tsx b/src/app/page.tsx index 47093e50..12f5197a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,7 +21,7 @@ export default async function Home({ searchParams: SearchParams; }) { const { tab: rawTab, writer: rawWriter, page: rawPage, genre: rawGenre, lang: rawLang } = await searchParams; - const tab: Tab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : "new"; + const tab: Tab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : "trending"; const writer: WriterFilterValue = WRITER_VALUES.includes( rawWriter as WriterFilterValue, ) From dc29a6ef2d665ba52e60bdc8940823753888abc6 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 26 Mar 2026 11:59:54 +0000 Subject: [PATCH 2/2] [#560] Widen candidate pool to include recently updated stories Fetch two pools (newest by creation + recently active by last_plot_time) and merge/deduplicate, so older stories with fresh plot activity are eligible for the recency boost instead of being cut off by the block_timestamp ordering. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/ranking.ts | 48 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/lib/ranking.ts b/lib/ranking.ts index c817db3a..3dde64d3 100644 --- a/lib/ranking.ts +++ b/lib/ranking.ts @@ -82,20 +82,40 @@ async function fetchCandidatesAndRatings( genre?: string, lang?: string, ) { - let q = supabase.from("storylines") - .select("*") - .eq("hidden", false) - .eq("sunset", false) - .neq("token_address", "") - .eq("contract_address", STORY_FACTORY.toLowerCase()); - if (writerType !== undefined) q = q.eq("writer_type", writerType); - if (genre) q = q.eq("genre", genre); - if (lang) q = q.eq("language", lang); - const { data } = await q - .order("block_timestamp", { ascending: false }) - .limit(50); - - const storylines = (data ?? []) as Storyline[]; + function applyBase(q: ReturnType) { + let filtered = q + .eq("hidden", false) + .eq("sunset", false) + .neq("token_address", "") + .eq("contract_address", STORY_FACTORY.toLowerCase()); + if (writerType !== undefined) filtered = filtered.eq("writer_type", writerType); + if (genre) filtered = filtered.eq("genre", genre); + if (lang) filtered = filtered.eq("language", lang); + return filtered; + } + + // Two pools: newest by creation + recently updated by last_plot_time + const [byCreated, byActivity] = await Promise.all([ + applyBase(supabase.from("storylines").select("*")) + .order("block_timestamp", { ascending: false }) + .limit(50), + applyBase(supabase.from("storylines").select("*")) + .not("last_plot_time", "is", null) + .order("last_plot_time", { ascending: false }) + .limit(50), + ]); + + // Merge and deduplicate by storyline_id + const seen = new Set(); + const merged: Storyline[] = []; + for (const sl of [...(byCreated.data ?? []), ...(byActivity.data ?? [])] as Storyline[]) { + if (!seen.has(sl.storyline_id)) { + seen.add(sl.storyline_id); + merged.push(sl); + } + } + + const storylines = merged; if (storylines.length === 0) return { storylines, ratingMap: new Map() }; // Batch: fetch all ratings for candidate storyline IDs in one query