Ranking trending entries in a seven-day window

ยท ยท 34 views
Table of Contents

The ๐Ÿ”ฅ Trending view answers a deceptively simple question: of the feeds I already follow, what are other people rescuing from the firehose right now? It sits in the sidebar under "Smart Views", one tap away, and it renders a numbered leaderboard of entries. I want to walk through how the ranking actually works, because the interesting part is how little machinery it needs โ€” one Ecto query, no cron job, no materialized table, no LLM.

Every entry in SlashFeed can accumulate two kinds of explicit human signal: someone saves it for later, or someone stars it. Both are deliberate acts. Nobody saves an article by accident the way they might open one, which is exactly why these are the signals I wanted to rank on. Reading, by contrast, is noisy โ€” it inflates with headlines skimmed and abandoned โ€” so reading does not affect the trending score at all. Only saves and stars count.

The score is the plain sum of the two, counted within a rolling seven-day window and aggregated across all users, then filtered down to the feeds you personally subscribe to. So a post is "trending" when your fellow subscribers have recently decided it was worth keeping.

The query

The whole feature lives in Feeds.trending_entries/2 in lib/boring_rss/feeds.ex. Here is the core of it:

def trending_entries(user, opts \\ []) do
  limit = Keyword.get(opts, :limit, 20)
  days = Keyword.get(opts, :days, 7)
  since = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)

  from(e in Entry,
    join: f in Feed,
    on: e.feed_id == f.id,
    join: uf in UserFeed,
    on: uf.feed_id == f.id and uf.user_id == ^user.id,
    left_join: sl in SaveLaterEntry,
    on: sl.entry_id == e.id and sl.saved_at >= ^since,
    left_join: se in StarredEntry,
    on: se.entry_id == e.id and se.starred_at >= ^since,
    group_by: [e.id, f.id, uf.category_id],
    having: count(sl.id) + count(se.id) > 0,
    order_by: [desc: count(sl.id) + count(se.id)],
    limit: ^limit,
    select: %{
      entry: e,
      feed: f,
      category_id: uf.category_id,
      score: count(sl.id) + count(se.id),
      save_count: count(sl.id),
      star_count: count(se.id)
    }
  )
  |> Repo.all()
end

A few things are load-bearing here, so let me unpack them.

The inner join on UserFeed is the scoping. By joining entries to UserFeed with uf.user_id == ^user.id, an entry can only survive if it belongs to a feed this user subscribes to. There's no separate "is this in my feeds?" pass โ€” the join is the filter. It also gives me uf.category_id for free, which is why it's carried through into the select and the group_by.

The two left_joins are where the window lives. Saves and stars are separate tables (SaveLaterEntry, StarredEntry), and the seven-day cutoff is pushed into the join condition โ€” sl.saved_at >= ^since and se.starred_at >= ^since โ€” rather than into a where. That distinction matters. If I'd put the time filter in a where, an entry with zero recent saves would be dropped before I could evaluate its stars. As a left_join condition, rows outside the window simply contribute NULL, which count/1 ignores, and the entry still gets a fair shot at qualifying on its other signal.

count(sl.id) + count(se.id) is the entire scoring function. No decay, no weighting, no normalization. A star is worth exactly one point and a save is worth exactly one point. The same expression appears three times โ€” in having, in order_by, and in select as score โ€” which is a little repetitive but keeps Postgres doing all the arithmetic in one aggregate pass. The having clause count(sl.id) + count(se.id) > 0 throws away entries that have neither signal in the window, so an empty result set is genuinely empty rather than a list of zeros.

The LiveView (lib/boring_rss_web/live/trending_live/index.ex) then just renders the maps: the numbered badge from Enum.with_index/1, the score next to a fire icon, and save_count / star_count broken out underneath. No client-side sorting โ€” the order comes straight from the database.

On not weighting saves versus stars

I went back and forth on whether a star should outweigh a save. Stars feel more emphatic. But the honest answer is that I didn't have the data to justify a coefficient, and a made-up 2x multiplier is worse than no multiplier because it looks principled while being arbitrary. Equal weighting is defensible and legible: the badge shows both counts, so a curious user can see exactly how a score was assembled. If we ever learn that stars predict long-term value better, the change is a one-character edit โ€” multiply one count โ€” not a rewrite.

The seven-day window is the other knob, exposed as the :days option. Seven days is long enough to accumulate signal on a mid-size instance but short enough that the list turns over weekly instead of ossifying into an all-time hall of fame. It's a DateTime.add at call time, so the window is always relative to now; there's nothing to expire or recompute.

How this differs from Smart Views and the recommender

Trending shares a sidebar heading with Smart Views, but they're different animals. A Smart View is a saved HighlightRule โ€” a per-user filter over content ("show me unread entries matching this rule"). Trending isn't a filter and it isn't personal beyond the subscription scope; it's a cross-user popularity aggregate. Same neighborhood, different mechanism.

It's also worth drawing the line against the Neo4j recommendation engine (lib/boring_rss/neo4j/recommender.ex). That system recommends feeds you don't yet follow, using collaborative filtering, topic overlap, and co-read sessions โ€” and it has its own "trending / cold-start" tier for globally popular feeds. Trending-the-view is the opposite direction: it ranks entries inside feeds you already have, and it lives entirely in Postgres. When Neo4j is disabled โ€” the default in test and in production without a graph instance โ€” Trending keeps working, because it never depended on the graph in the first place. That independence was deliberate: the most-used discovery surface shouldn't fall over when an optional service isn't running.