an app to share curated trails sidetrail.app
1

Configure Feed

Select the types of activity you want to include in your feed.

tweak algo

+10 -15
+6 -6
data/__tests__/trails.test.ts
··· 78 78 createdAt: now(), 79 79 }); 80 80 81 - // Appears in home feed as fallback (tier 1) 81 + // Appears in home feed even without activity 82 82 const trails = await queries.loadTrails(); 83 83 expect(trails).toHaveLength(1); 84 84 expect(trails[0].title).toBe("Lonely Trail"); ··· 665 665 createdAt: sixtyDaysAgo, 666 666 }); 667 667 668 - // Create another trail outside grace period but with no activity 668 + // Create another newer trail with no activity 669 669 const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); 670 670 await emit("app.sidetrail.trail", BOB.did, { 671 671 $type: "app.sidetrail.trail", 672 672 title: "New Trail No Activity", 673 - description: "Outside grace period but no one has walked", 673 + description: "Newer but no one has walked", 674 674 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 675 675 accentColor: "#222", 676 676 backgroundColor: "#ddd", ··· 688 688 689 689 const trails = await queries.loadTrails(); 690 690 691 - // Both trails are tier 2 (outside grace period, < 5 activity), but trail with activity ranks higher 691 + // Recent activity outweighs creation recency 692 692 expect(trails).toHaveLength(2); 693 693 expect(trails[0].title).toBe("Old Trail With Activity"); 694 694 expect(trails[1].title).toBe("New Trail No Activity"); ··· 739 739 740 740 const trails = await queries.loadTrails(); 741 741 742 - // Both trails appear, but Trail B (with non-author activity) ranks first (tier 0) 743 - // Trail A is in tier 1 (fallback) since author activity doesn't count 742 + // Both trails appear, but Trail B (with non-author activity) ranks first 743 + // since author activity doesn't count toward the score 744 744 expect(trails).toHaveLength(2); 745 745 expect(trails[0].title).toBe("Trail B - Non-Author Walk"); 746 746 expect(trails[1].title).toBe("Trail A - Author Self-Walk");
+4 -9
data/queries.ts
··· 234 234 const HALF_LIFE_HOURS = 12; 235 235 const DECAY_RATE = Math.log(2) / HALF_LIFE_HOURS; // ≈ 0.0578 236 236 const RECENT_ACTIVITY_WINDOW_HOURS = 3; 237 - const GRACE_PERIOD_HOURS = 3; // new trails get exposure 238 - const MIN_ACTIVITY_FOR_RANKING = 5; // need this many to rank in main tier 239 237 240 238 export const loadTrails = cache(async function loadTrails(): Promise<TrailCardData[]> { 241 239 const db = getDb(); ··· 262 260 AND COALESCE(acc.shadowban, 0) = 0 263 261 GROUP BY t.uri, t.cid, t.author_did, t.rkey, t.record, t.created_at, t.indexed_at 264 262 ORDER BY 265 - -- Tier: 0 = grace period (new), 1 = proven (enough activity), 2 = unproven 266 - CASE 267 - WHEN EXTRACT(EPOCH FROM (NOW() - t.created_at))/3600 < ${GRACE_PERIOD_HOURS}::float THEN 0 268 - WHEN COUNT(a.trail_uri) >= ${MIN_ACTIVITY_FOR_RANKING} THEN 1 269 - ELSE 2 270 - END, 271 - -- Score within tier: (1 + sqrt(recent_activity)) * recency_decay 263 + -- Score: (1 + sqrt(recent_activity)) * recency_decay. 264 + -- Self-calibrating: a brand-new trail scores 1.0, so it outranks anything 265 + -- whose last activity is older than ~one half-life, while trails that are 266 + -- hot right now stay above it. No tiers or grace windows needed. 272 267 (1 + SQRT(SUM(CASE WHEN EXTRACT(EPOCH FROM (NOW() - a.created_at))/3600 < ${RECENT_ACTIVITY_WINDOW_HOURS}::float THEN 1 ELSE 0 END))) 273 268 * EXP(${DECAY_RATE}::float * -1.0 * EXTRACT(EPOCH FROM (NOW() - GREATEST(t.created_at, COALESCE(MAX(a.created_at), t.created_at))))/3600) 274 269 DESC