···7878 createdAt: now(),
7979 });
80808181- // Appears in home feed as fallback (tier 1)
8181+ // Appears in home feed even without activity
8282 const trails = await queries.loadTrails();
8383 expect(trails).toHaveLength(1);
8484 expect(trails[0].title).toBe("Lonely Trail");
···665665 createdAt: sixtyDaysAgo,
666666 });
667667668668- // Create another trail outside grace period but with no activity
668668+ // Create another newer trail with no activity
669669 const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
670670 await emit("app.sidetrail.trail", BOB.did, {
671671 $type: "app.sidetrail.trail",
672672 title: "New Trail No Activity",
673673- description: "Outside grace period but no one has walked",
673673+ description: "Newer but no one has walked",
674674 stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
675675 accentColor: "#222",
676676 backgroundColor: "#ddd",
···688688689689 const trails = await queries.loadTrails();
690690691691- // Both trails are tier 2 (outside grace period, < 5 activity), but trail with activity ranks higher
691691+ // Recent activity outweighs creation recency
692692 expect(trails).toHaveLength(2);
693693 expect(trails[0].title).toBe("Old Trail With Activity");
694694 expect(trails[1].title).toBe("New Trail No Activity");
···739739740740 const trails = await queries.loadTrails();
741741742742- // Both trails appear, but Trail B (with non-author activity) ranks first (tier 0)
743743- // Trail A is in tier 1 (fallback) since author activity doesn't count
742742+ // Both trails appear, but Trail B (with non-author activity) ranks first
743743+ // since author activity doesn't count toward the score
744744 expect(trails).toHaveLength(2);
745745 expect(trails[0].title).toBe("Trail B - Non-Author Walk");
746746 expect(trails[1].title).toBe("Trail A - Author Self-Walk");
+4-9
data/queries.ts
···234234const HALF_LIFE_HOURS = 12;
235235const DECAY_RATE = Math.log(2) / HALF_LIFE_HOURS; // ≈ 0.0578
236236const RECENT_ACTIVITY_WINDOW_HOURS = 3;
237237-const GRACE_PERIOD_HOURS = 3; // new trails get exposure
238238-const MIN_ACTIVITY_FOR_RANKING = 5; // need this many to rank in main tier
239237240238export const loadTrails = cache(async function loadTrails(): Promise<TrailCardData[]> {
241239 const db = getDb();
···262260 AND COALESCE(acc.shadowban, 0) = 0
263261 GROUP BY t.uri, t.cid, t.author_did, t.rkey, t.record, t.created_at, t.indexed_at
264262 ORDER BY
265265- -- Tier: 0 = grace period (new), 1 = proven (enough activity), 2 = unproven
266266- CASE
267267- WHEN EXTRACT(EPOCH FROM (NOW() - t.created_at))/3600 < ${GRACE_PERIOD_HOURS}::float THEN 0
268268- WHEN COUNT(a.trail_uri) >= ${MIN_ACTIVITY_FOR_RANKING} THEN 1
269269- ELSE 2
270270- END,
271271- -- Score within tier: (1 + sqrt(recent_activity)) * recency_decay
263263+ -- Score: (1 + sqrt(recent_activity)) * recency_decay.
264264+ -- Self-calibrating: a brand-new trail scores 1.0, so it outranks anything
265265+ -- whose last activity is older than ~one half-life, while trails that are
266266+ -- hot right now stay above it. No tiers or grace windows needed.
272267 (1 + SQRT(SUM(CASE WHEN EXTRACT(EPOCH FROM (NOW() - a.created_at))/3600 < ${RECENT_ACTIVITY_WINDOW_HOURS}::float THEN 1 ELSE 0 END)))
273268 * EXP(${DECAY_RATE}::float * -1.0 * EXTRACT(EPOCH FROM (NOW() - GREATEST(t.created_at, COALESCE(MAX(a.created_at), t.created_at))))/3600)
274269 DESC