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
246 changes: 246 additions & 0 deletions lib/ranking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
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, 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 {
// Rating signal (0-1), weight: 0.3
const ratingSignal = avgRating / 5;

// Price change signal (0-1), weight: 0.25
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
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
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
);
}

/** 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("*")
.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 { storylines, ratingMap: new Map<number, number>() };

// 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);

const ratingMap = new Map<number, number>();
if (allRatings) {
const grouped = new Map<number, number[]>();
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);
}
}

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<RankedStoryline[]> {
const { storylines, ratingMap } = await fetchCandidatesAndRatings(supabase);
if (storylines.length === 0) return [];

const enriched = await Promise.all(
storylines.map(async (sl): Promise<RankedStoryline> => {
const avgRating = ratingMap.get(sl.storyline_id) ?? 0;
const { priceChange, tvlRaw, tvlDecimals } = await enrichWithOnChain(sl);

const trendScore = computeTrendScore(
avgRating,
priceChange,
tvlRaw,
tvlDecimals,
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 signals.
*
* Computes the same 4 signals in a recent window (last 3 days) vs
* 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,
limit = 20,
): Promise<RankedStoryline[]> {
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();

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")
.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);

// 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);
if (filtered.length === 0) return 0;
return filtered.reduce((s, r) => s + r.rating, 0) / filtered.length;
}

function countFromRows(rows: { storyline_id: number }[] | null, slId: number): number {
if (!rows) return 0;
return rows.filter((r) => r.storyline_id === slId).length;
}

// 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);
}
33 changes: 3 additions & 30 deletions src/app/discover/page.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -35,12 +36,6 @@ export default async function DiscoverPage({

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

{(tab === "trending" || tab === "rising") && (
<p className="text-muted mt-4 text-xs italic">
Ranking by recency — trading-based ranking available after Phase 5.
</p>
)}

<div className="mt-6 space-y-3">
{storylines.map((s) => (
<StoryCard key={s.id} storyline={s} genre="fiction" />
Expand Down Expand Up @@ -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<Storyline[]>();
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<Storyline[]>();
return data ?? [];
return getRisingStorylines(supabase, 20);
}
}
}
Loading