# Port: `edtrending` → `extensions/trending`

2026-08-11

The sixth port, chosen the way the others were — by benefit to the site rather
than by what was easiest. Discovery is the bottleneck Ernest has named
repeatedly; SEO and Related Topics were both picked for it, and this is the
third in that line. It is also a *placeable block*, which lands exactly on the
widget type registry built on 2026-08-08, so the seam it needs already exists.

## The score, kept exactly

    score = (replies × wPosts + reactions × wReacts) ÷ (ageHours + 2) ^ gravity

Replies and reactions count only inside the window; `ageHours` is measured from
the topic's **last** reply. The `+ 2` is what stops a minute-old topic with one
reply from beating a thread with forty, and the exponent is what makes a busy
old thread fall away instead of sitting at the top of "trending" forever.

Defaults are the original's — 72-hour window, 8 topics, a reply worth 1, a
reaction worth 2, gravity 1.5. A reaction outweighs a reply on purpose: reacting
is cheaper than writing, and a thread people *enjoy* is not always a thread
people argue in.

## Two things the port does not do the same way

**🚨 No cron.** The original recomputed on a scheduled task into a stored list.
Convoro has to install by uploading a zip to shared hosting, where there may be
no cron at all — and a block that silently never updates is worse than one that
costs a query. So it computes when somebody asks and caches the answer for a
configurable few minutes: one query per interval rather than one per visitor,
and nothing to set up. This is the same constraint that produced Admin → Updates
earlier the same day.

**🚨 Visibility is filtered inside the query.** The original scored everything,
sorted, capped at KEEP, and *then* dropped what a guest could not see. Convoro
has private forums, and filtering after the cap silently shrinks the block — ask
for eight, get three, with nothing saying why. It is the trap the handoff already
records for search, streams and the recent-topics widget, and the reason every
one of them filters in the WHERE.

A title is a leak even when the link 403s, so the check is closed rather than
open: if the visibility lookup itself throws, the block renders nothing.

**And the cache is keyed per audience.** One entry for everybody would hand a
private forum's topic titles to whoever loaded the page second. The key carries
the readable forum set.

## Found while porting

- **`posts.is_hidden` exists and the first draft ignored it.** A moderated reply
  would have pushed its thread up the block — the one place a hidden post must
  not have influence.
- **Convoro has no per-post reaction count.** IPS reads a denormalised
  `post_reaction_count`; here it is a `LEFT JOIN` and a `COUNT(DISTINCT r.id)`,
  which is why the reply count is also `DISTINCT` — without it the join
  multiplies replies by reactions and a popular post counts several times.
- **Gravity must never reach zero.** At zero every divisor is 1, nothing cools,
  and the block freezes on whatever was busiest that week and never changes.
  Clamped on the way *out* as well as in, because a value can reach the row
  before a validator exists.
- **`ReflectionMethod::setAccessible()` is deprecated in 8.5.** The settings
  test reflected on a private method; it goes through `save()` and `all()` now,
  which is the better test anyway — reflection would keep passing even if the
  public path stopped clamping.
- The seed helper needed `content` *and* `content_plain`; both are NOT NULL with
  no default, and each failure only appears after the previous one is fixed.

## What it is

6 tests, every string translatable, the widget registered through
`widget_types` rather than by editing anyone else's renderer, and verified
against the real database rather than reasoned about — the busiest recent thread
ranks first, and reactions move a quieter one above a busier one, which is the
formula doing its job.

## The admin screen

Built in the following commit, and the **preview is the reason it exists**. Six
numbers that reorder a list in ways nobody can predict are six numbers nobody
will touch — so the page opens with what the block holds right now, scored by
the saved settings, and an operator can change one, save, and watch it move.
That turns "gravity 1.5" from a guess into something with a visible consequence.

Two decisions inside it:

- **Clamping stays in the settings service and only there.** A second set of
  bounds in a controller is a second set to drift, and the CLI or a future API
  would miss it. The form's `min`/`max` mirror the clamps so the browser says
  the same thing, but they are not the enforcement.
- **Saving flushes the cache** rather than letting it expire. Changing a weight
  and then seeing the old order for ten minutes reads as the setting not
  working, and the operator changes something else to compensate.

🚨 **The screenshot pass caught a copy bug nothing else would have.** The
preview's column headings reused the *weight* labels — so the reply-count column
was headed "A reply is worth", and the screen read "A reply is worth 73". The
strings were all translatable, every test passed, and the sentence was nonsense.
They have their own keys now.
