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.

// computeRankScore(tool) — pure function, no DB read

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
â„šī¸ No subscription join in the formula. The old approach joined the 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.

+25
verified = true
Quality badge — admin confirmed the tool is legitimate, working, and well-described.
+30→0
recency decay
Automatic. ~30 pts on publish day, decaying to near-zero over ~30 days. Not a badge — formula-computed.
âš ī¸ Badge bonuses are stored in the 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:

recencyBoost = 30 / (daysSincePublished + 1)

// 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:

getToolBySlug (new view)
fire-and-forget
viewCount incremented → then updateRankScore() chained. Non-blocking — response returns before score is written.
trackToolClick
fire-and-forget
clickCount incremented → then updateRankScore() chained. Non-blocking. clickCount is a high-weight signal (×2.5).
toggleUpvote
awaited
upvoteCount incremented or decremented → updateRankScore() awaited before response. Upvotes are the highest-weight signal (×4.0).
createReview
awaited
rating + reviewCount recalculated → updateRankScore() awaited. Both inputs feed the quality term (rating × reviewCount × 3).
rateTool
awaited
Aggregate rating recomputed from all ToolRating rows → updateRankScore() awaited.
createTool
awaited
Initial rankScore set on creation. Mostly recency boost at this point. Ensures the tool is not stuck at 0 when it first appears in listings.
updateTool
awaited
featured / verified / trending / publishedAt may have changed → rankScore must reflect the updated badges.
updateToolStatus (admin)
awaited
Admin sets featured, verified, or publishes a tool → rankScore updated immediately so the badge bonus appears in listings without delay.
â„šī¸ Fire-and-forget vs awaited: views and clicks are fire-and-forget because the user does not need to wait for the ranking update — the response can go back immediately. Upvotes, reviews, and status changes are awaited because users expect to see the result of their action reflected quickly, and these carry higher formula weight.
âš ī¸ Admin-only. Featured status is set manually via 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:

// getFeaturedTools() — no longer uses raw SQL
prisma.tool.findMany({
  where: { status: "active" },
  take: limit,
  orderBy: [{ rankScore: "desc" }, { publishedAt: "desc" }],
  // ↑ Tiebreaker: newer tool wins when scores are equal
})
QuestionAnswerReason
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

What "auto-rotate" means

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 = true tools and selects the next N slots using a deterministic shuffle based on hash(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.
â„šī¸ Current status: NOT IMPLEMENTED — the featured endpoint currently returns all 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:

  1. 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.
  2. 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 to 0.
â„šī¸ Draft tools: tools without a 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:

// One-time backfill after migration, or after formula change:
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.

âš ī¸ Never sort by live 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.

❓ Q: My tool has zero views, zero clicks, and zero upvotes. Why is its 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:

upvotes × 4.0 = 0 × 4.0 = 0.00
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.

❓ Q: What are the minimum and maximum possible rankScore values?
~30
Brand new, no badges
Day 0, no editorial flags, no engagement — pure recency boost at publish.
~0.08
Aged, no badges, no engagement
Published 365+ days ago, zero stats, no editorial flags. Score asymptotically approaches 0.
Unbounded
High engagement
Grows linearly with upvotes (×4) and clicks (×2.5). A viral tool will always outrank any editorial-only score.

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.

❓ Q: If a tool has engagement stats at zero, can its score ever go down on its own?

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