Skip to content
Merged
Show file tree
Hide file tree
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
104 changes: 74 additions & 30 deletions lib/ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,48 +11,67 @@ 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();
const ageDays = Math.max(1, ageMs / (1000 * 60 * 60 * 24));
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
);
}

Expand All @@ -63,21 +82,41 @@ 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);
function applyBase(q: ReturnType<typeof supabase.from>) {
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<number>();
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 = (data ?? []) as Storyline[];
if (storylines.length === 0) return { storylines, ratingMap: new Map<number, number>() };
const storylines = merged;
if (storylines.length === 0) return { storylines, ratingMap: new Map<number, { avg: number; count: number }>() };

// Batch: fetch all ratings for candidate storyline IDs in one query
const storylineIds = storylines.map((sl) => sl.storyline_id);
Expand All @@ -86,7 +125,7 @@ async function fetchCandidatesAndRatings(
.in("storyline_id", storylineIds)
.eq("contract_address", STORY_FACTORY.toLowerCase());

const ratingMap = new Map<number, number>();
const ratingMap = new Map<number, { avg: number; count: number }>();
if (allRatings) {
const grouped = new Map<number, number[]>();
for (const r of allRatings) {
Expand All @@ -95,7 +134,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,
});
}
}

Expand Down Expand Up @@ -135,16 +177,18 @@ export async function getTrendingStorylines(

const enriched = await Promise.all(
storylines.map(async (sl): Promise<RankedStoryline> => {
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 };
Expand Down
2 changes: 1 addition & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Loading