A community based topic aggregation platform built on atproto

fix(feeds): ensure new posts appear in hot ranking

Hot ranking formula now uses (score + 1) instead of score to prevent
new posts with 0 votes from sinking to the bottom. Previously,
0 / time_decay = 0 caused all unvoted posts to have rank 0.

Affects discover, timeline, and community feed repos.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+13 -6
+5 -2
internal/db/postgres/discover_repo.go
··· 12 12 } 13 13 14 14 // sortClauses maps sort types to safe SQL ORDER BY clauses 15 + // Note: Hot ranking uses (score + 1) to ensure new posts with 0 votes still appear 16 + // (otherwise 0/time_decay = 0 and they sink to the bottom) 15 17 var discoverSortClauses = map[string]string{ 16 - "hot": `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 18 + "hot": `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 17 19 "top": `p.score DESC, p.created_at DESC, p.uri DESC`, 18 20 "new": `p.created_at DESC, p.uri DESC`, 19 21 } 20 22 21 23 // hotRankExpression for discover feed 22 - const discoverHotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 24 + // Uses (score + 1) so new posts with 0 votes still get a positive rank 25 + const discoverHotRankExpression = `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 23 26 24 27 // NewDiscoverRepository creates a new PostgreSQL discover repository 25 28 func NewDiscoverRepository(db *sql.DB, cursorSecret string) discover.Repository {
+4 -2
internal/db/postgres/feed_repo.go
··· 13 13 14 14 // sortClauses maps sort types to safe SQL ORDER BY clauses 15 15 // This whitelist prevents SQL injection via dynamic ORDER BY construction 16 + // Note: Hot ranking uses (score + 1) to ensure new posts with 0 votes still appear 16 17 var communityFeedSortClauses = map[string]string{ 17 - "hot": `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 18 + "hot": `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 18 19 "top": `p.score DESC, p.created_at DESC, p.uri DESC`, 19 20 "new": `p.created_at DESC, p.uri DESC`, 20 21 } ··· 23 24 // NOTE: Uses NOW() which means hot_rank changes over time - this is expected behavior 24 25 // for hot sorting (posts naturally age out). Slight time drift between cursor creation 25 26 // and usage may cause minor reordering but won't drop posts entirely (unlike using raw score). 26 - const communityFeedHotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 27 + // Uses (score + 1) so new posts with 0 votes still get a positive rank 28 + const communityFeedHotRankExpression = `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 27 29 28 30 // NewCommunityFeedRepository creates a new PostgreSQL feed repository 29 31 func NewCommunityFeedRepository(db *sql.DB, cursorSecret string) communityFeeds.Repository {
+4 -2
internal/db/postgres/timeline_repo.go
··· 13 13 14 14 // sortClauses maps sort types to safe SQL ORDER BY clauses 15 15 // This whitelist prevents SQL injection via dynamic ORDER BY construction 16 + // Note: Hot ranking uses (score + 1) to ensure new posts with 0 votes still appear 16 17 var timelineSortClauses = map[string]string{ 17 - "hot": `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 18 + "hot": `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5)) DESC, p.created_at DESC, p.uri DESC`, 18 19 "top": `p.score DESC, p.created_at DESC, p.uri DESC`, 19 20 "new": `p.created_at DESC, p.uri DESC`, 20 21 } 21 22 22 23 // hotRankExpression is the SQL expression for computing the hot rank 23 24 // NOTE: Uses NOW() which means hot_rank changes over time - this is expected behavior 24 - const timelineHotRankExpression = `(p.score / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 25 + // Uses (score + 1) so new posts with 0 votes still get a positive rank 26 + const timelineHotRankExpression = `((p.score + 1) / POWER(EXTRACT(EPOCH FROM (NOW() - p.created_at))/3600 + 2, 1.5))` 25 27 26 28 // NewTimelineRepository creates a new PostgreSQL timeline repository 27 29 func NewTimelineRepository(db *sql.DB, cursorSecret string) timeline.Repository {