Tool Listing & Ranking Strategy
How tools are ordered on the platform â general listings, featured section, and
sortBy=rank on the public API. Ranking is pre-computed
and stored as a rankScore float column on every Tool row. It is updated
on every engagement event rather than by a periodic cron job.
rankScore Formula
The score is a weighted sum of engagement signals plus editorial bonuses and a time-decay
recency boost. Computed in computeRankScore() inside
src/tools/tool.service.ts.
rankScore =
// ââ Engagement signals ââââââââââââââââââââââââââââââââââââââ
upvoteCount à 4.0 // strongest community signal â explicit endorsement
+ clickCount à 2.5 // strong intent signal â user clicked through to the tool
+ viewCount à 0.05 // weak, dampened â passive, easily gamed without dampening
// ââ Quality signal ââââââââââââââââââââââââââââââââââââââââââ
+ rating à reviewCount à 3.0 // quality à credibility: a 4.5â with 20 reviews > a 5â with 1
// ââ Editorial bonuses âââââââââââââââââââââââââââââââââââââââ
+ featured ? 50 : 0 // admin spotlight â manually curated
+ verified ? 25 : 0 // quality badge â admin-reviewed tool
+ trending ? 30 : 0 // trending flag â admin-set editorial push
// ââ Recency decay âââââââââââââââââââââââââââââââââââââââââââ
+ 30 / (daysSincePublished + 1) // ~30 pts on day 0, ~15 after 1 day, ~3 after 9 days
Plan table at query time to add a tier weight. That required raw SQL and
an expensive join on every featured-tools request. The editorial badges
(featured +50, verified +25) replace it â quality tools
still float to the top, and the admin controls the editorial signals.
Signal Weights Explained
| Signal | Weight | Why this weight | Gaming risk & mitigation |
|---|---|---|---|
| upvoteCount | Ã 4.0 | One-per-user toggle. Requires auth. Explicit intent â user chose to endorse. | Low. Needs account creation per vote. Auth barrier is the mitigation. |
| clickCount | Ã 2.5 | High-intent signal: user read the listing and wanted to visit the actual tool. | Medium. No dedup currently. Mitigation: consider rate limiting this endpoint per IP. |
| viewCount | Ã 0.05 | Passive signal â just loading the page. Dampened heavily to prevent spam dominance. | Deduplicated per browser via cookie (viewed_tools). Weight kept low so bots can't dominate. |
| rating à reviewCount | à 3.0 | Combines quality (star average) with credibility (number of reviews). A 5â from 1 person carries less weight than a 4.5â from 30 people. | Low. One review per user per tool. Requires auth. |
Editorial Badge Bonuses
These are flat point bonuses added to the score. All three flags are admin-only â
set via PATCH /api/tools/:id/status. They give the editorial team a lever
to boost quality tools independent of engagement volume.
rankScore column via
updateRankScore(), which runs after every updateToolStatus()
or updateTool() call. Toggling a badge takes effect in the ranking
immediately â the KV cache is also invalidated at the same time, so the list
endpoints reflect the change within seconds.
Recency Decay
A brand-new tool has no views, no clicks, no upvotes. Without a boost it would always sink to the bottom of every listing. The recency term gives every tool a head start that fades naturally:
// Day 0 (just published) â 30.0 pts
// Day 1 â 15.0 pts
// Day 5 â ~5.0 pts
// Day 9 â ~3.0 pts
// Day 29 â ~1.0 pts
// Tool not yet published â 365 days assumed â ~0.1 pts (draft penalty)
The decay is continuous â it is recomputed on every call to updateRankScore(),
meaning a tool's score naturally drops over time even with no new engagement events, as long
as some engagement event triggers the next recompute. A completely inactive tool (no clicks,
no new upvotes, nothing) will not have its score updated. Run the admin backfill endpoint
periodically if you want stale scores refreshed.
When rankScore Updates
There is no cron job. rankScore is updated event-driven â
every action that changes an input signal triggers a recompute. Two update modes are used:
Featured Section
PATCH /api/tools/:id/status with { "featured": true }.
It is never assigned automatically.
The featured section uses the same GET /api/tools/featured endpoint as before,
but the ordering changed. Previously it used a raw SQL join to rank by Plan.order
(subscription tier). Now it orders by rankScore DESC, publishedAt DESC:
prisma.tool.findMany({
where: { status: "active" },
take: limit,
orderBy: [{ rankScore: "desc" }, { publishedAt: "desc" }],
// â Tiebreaker: newer tool wins when scores are equal
})
| Question | Answer | Reason |
|---|---|---|
| Who sets featured? | Admin only | Editorial trust signal â prevents gaming by fake reviews or view bots |
| What order are featured tools shown? | rankScore DESC |
Best-quality featured tools surface first within the featured pool |
| CDN purge on change? | Immediate (KV version bump) | Admin action must be visible in listings within seconds |
| Can it auto-rotate? | Planned â not yet implemented | See Auto-Rotate section below |
Auto-Rotate â What It Means & How It Would Work
Imagine the admin has marked 20 tools as featured. But the homepage
only has room to show 6 featured slots. Without rotation, the same 6
tools (highest rankScore) would be frozen in those slots indefinitely â
tools ranked 7â20 never get exposure even though an admin approved them.
Auto-rotate means the system automatically cycles which 6 tools from that admin-approved pool of 20 are displayed â swapping them out on a schedule (e.g. every 7 days). The admin doesn't have to manually change anything; the featured section feels fresh to returning visitors while still being controlled by the admin's approved pool.
The rotation cycle would work like this:
- Admin marks tools as
featured = true. This is the pool â the set of admin-approved candidates. - A Cloudflare Cron Trigger fires on a schedule (e.g. every Sunday). It reads all
featured = truetools and selects the next N slots using a deterministic shuffle based onhash(toolId + weekNumber)â same result for the whole week, no randomness per request. - The selected N tool IDs are written to a KV key (
featured:active-slots). The featured endpoint reads this key instead of querying all featured tools. - On the next cron tick, a different subset is selected from the same admin-approved pool.
featured = true tools ordered by rankScore. There is no slot
limit and no rotation schedule. The rotation design above is documented here so the
mechanism is understood before it is built.
Why not just always show all featured tools? At 3â5 featured tools it doesn't matter. At 15â30 it becomes a wall. Rotation keeps scarcity (which maintains the value of the featured badge) without the admin having to log in every week to manually swap tools in and out.
Cold Start (New Tools)
A newly published tool has upvoteCount = 0, clickCount = 0,
viewCount = 0, rating = 0, reviewCount = 0.
Without intervention it would always be at the bottom. Two mechanisms prevent this:
-
Recency boost (up to +30 points) â applied automatically in the formula for all tools based on
publishedAt. On the day of publish: ~30 pts. Decays to near-zero over ~30 days. This gives every new tool a temporary lift above older tools with similar (low) engagement. -
Initial rankScore on createTool() â
updateRankScore()is called immediately after creation. So the tool enters the DB with a non-zero score (recency boost + any editorial badges if set) rather than defaulting to0.
publishedAt date are treated
as 365 days old in the recency formula, giving them ~0.08 pts. This is intentional â drafts
should not rank until published.
Backfill & Recovery
The rankScore column was added in migration 0009_add_rank_score.sql.
All existing tools default to 0 until recalculated. The admin backfill endpoint
handles this:
POST /api/tools/admin/recalculate-rankings
// â Fetches all non-archived tools in batches of 100
// â Calls computeRankScore() for each and writes rankScore back
// â Bumps KV cache version to invalidate all list caches
// â Returns: { updated: N }
Also use this endpoint after changing the formula weights in computeRankScore()
â existing stored scores reflect the old weights and need to be refreshed.
KV Cache Strategy
Because rankScore is pre-computed and written to the DB on each engagement
event, list pages can be cached aggressively. The cached result is the sorted list â not
a live DB query on every request.
| Endpoint | TTL | Purge Trigger | Notes |
|---|---|---|---|
| GET /tools (list) | 60s | Any tool write bumps cache version | Short TTL â active tools update frequently |
| GET /tools/featured | 5 min | Any tool write bumps cache version | Featured section changes less often; longer TTL is fine |
| GET /tools/slug/:slug | Not cached | N/A | Detail page fetches live data â view tracking must be real-time |
Cache invalidation uses a version key pattern: a single
tools:v integer in KV. Every tool write increments it. All list cache keys
embed the current version (tools:list:{version}:{query}), so bumping the
version orphans all prior cache entries without needing pattern-based key deletion (which
KV does not support). Orphaned entries expire naturally via their TTL.
viewCount at query time. It changes on
every page view and forces constant cache misses. Always sort by the pre-computed
rankScore column â use ?sortBy=rank on the list endpoint.
Frequently Asked Questions
Common questions about how rankScore behaves, answered with exact numbers
so there is no ambiguity.
rankScore a non-zero number like 76.67?
Three components contribute to the score even when all engagement stats are zero:
| Component | Condition | Points added |
|---|---|---|
| featured | featured = true (admin-set) |
+50.0 |
| verified | verified = true (admin-set) |
+25.0 |
| trending | trending = true (admin-set) |
+30.0 |
| recency decay | Automatic â 30 / (daysSincePublished + 1). Always present on published tools. | ~0.08 â ~30 |
Example breakdown â tool with featured=true, verified=true,
trending=false, published 17 days ago, all engagement at 0:
clicks à 2.5 = 0 à 2.5 = 0.00
views à 0.05 = 0 à 0.05 = 0.00
rating à reviews à 3 = 0.00
featured = true = +50.00
verified = true = +25.00
trending = false = 0.00
30 / (17 + 1) = +1.67 // recency, 17 days old
ââââââââââââââââââââââââââââââââââââââ
rankScore = 76.67
This is correct behaviour. Editorial badges are a deliberate head-start for curated tools. A featured + verified tool with no engagement still outranks an uncurated tool with a handful of views, which is exactly the intent. Engagement will grow and eventually overtake the fixed badge bonuses.
rankScore values?
There is no hard ceiling. The formula is intentionally unbounded upward so that organic engagement eventually always wins over editorial curation. Badges give a bootstrapping advantage, not a permanent ceiling.
Yes â but only when updateRankScore() is triggered by an engagement event.
The recency decay term (30 / (days + 1)) is recomputed every time the score is
written. A completely inactive tool (no clicks, no upvotes, nothing) will not have its score
recalculated automatically; the score stays frozen at whatever it was last written.
Use POST /api/tools/admin/recalculate-rankings to force a refresh of all stale scores.
Decision Summary
| Surface | Order by | Update trigger | Cache |
|---|---|---|---|
General listing (?sortBy=rank) |
rankScore DESC |
Engagement events | 60s TTL |
| Featured section | rankScore DESC |
Engagement + admin badge change | 5 min TTL |
| Featured rotation (future) | Admin pool, weekly shuffle | Cron + admin pool | Stable 7 days |
| Sort by name / createdAt / rating | Column direct sort | N/A â DB columns | 60s TTL (same list cache) |
| Tool detail page | N/A (single record) | Owner update / view increment | Not cached |
| New tools (cold start) | Recency boost in rankScore |
Set at createTool() | Same as general listing |