Coverage Matrix

Which resource types track which signals. KV buffering, click count, and rank score are Tools-only features currently.

Resource viewCount clickCount KV buffered? rankScore update Dedup method
/api/tools/:id/view βœ… Yes (client ping) β€” βœ… KV β†’ D1 via cron ⚠️ On cron sync Per-tool cookie viewed_tool_{id} β€” 24 h
/api/tools/:id/click β€” βœ… Yes (deduped) βœ… KV β†’ D1 via cron ⚠️ On cron sync Per-tool cookie clicked_tool_{id} β€” 1 h
/api/tools/slug/:slug βœ… Yes (cookie-gated) βœ… Yes β€” via POST /:id/click ⚠️ KV read for real-time merge only βœ… After each event Multi-slug cookie viewed_tools
/api/ai-for-lawyers/:slug βœ… Yes ⚠️ Shared β€” POST /api/tools/:id/click ⚠️ KV read for real-time merge only βœ… After view Per-slug cookie viewed_tool_{slug}
/api/ai-for-real-estate/:slug βœ… Yes ⚠️ Shared β€” POST /api/tools/:id/click ⚠️ KV read for real-time merge only βœ… After view Per-slug cookie viewed_tool_{slug}
/api/ai-for-videos/:slug ❌ No slug route ⚠️ Shared β€” POST /api/tools/:id/click β€” ❌ No view tracking β€”
/api/blogs/:slug βœ… Yes ❌ Not implemented ❌ Direct D1 write ❌ No rank score Multi-slug cookie viewed_blogs
/api/tutorials/:slug βœ… Yes ❌ Not implemented ❌ Direct D1 write ❌ No rank score Multi-slug cookie

KV Buffer Architecture

Tool view and click counts are not written directly to D1 on each request. Instead they are buffered in Cloudflare KV (a fast key-value store) and flushed to D1 by a cron job every 30 minutes. This prevents D1 write contention under high traffic and keeps the response latency low.

Browser ──POST /api/tools/:id/view──▢ CF Worker ──increment──▢ Cloudflare KV (fast, <1 ms) ◀── { success: true } ── Cron trigger (*/30 * * * *) ──flush KV β†’ D1──▢ D1 (SQLite) delete KV key after flush (restore on D1 failure)
KV key schema
v:{toolId}  β†’  pending view count for this tool (plain numeric string)
c:{toolId}  β†’  pending click count for this tool (plain numeric string)

Tools with zero buffered counts have no KV entry. The cron job lists all keys with prefix v: and c:, flushes them to D1, then deletes the keys.
ℹ️ Read-modify-write: KV has no native atomic increment. The worker reads the current value, adds 1, and writes it back. Under extreme concurrency an occasional +1 may be lost β€” acceptable for a view/click counter on a directory.
⚠️ CF Workers bundling constraint: Cloudflare Workers uses esbuild for static bundling at deploy time. Dynamic await import() calls at runtime are silently ignored β€” the block executes but nothing happens. All imports from counter.service.ts must be static top-level imports.

How It Works β€” Client-Side View Ping

Tool pages are CDN-cached β€” the backend GET /:slug route is never hit on a cached page load. View tracking therefore uses a client-side POST ping after the page renders. The frontend calls POST /api/tools/:id/view on every page load after checking a local dedup cookie.

1
Page renders (CDN cache hit) β€” backend GET is never reached. The frontend JavaScript fires after hydration.
2
Cookie dedup check β€” the server reads the request cookie header and looks for an exact match on viewed_tool_{id}. If found β†’ returns { counted: false } immediately. No KV write.
3
KV increment β€” if COUNTERS KV namespace is bound, the worker reads the current buffered count from v:{toolId}, adds 1, and writes it back. Response is returned immediately.
4
Fallback: direct D1 write β€” if KV is not bound (local dev), the worker writes directly to D1 via trackToolView(). This path is awaited before the response β€” CF Workers kills unawaited promises when the Response is returned.
5
Set dedup cookie β€” viewed_tool_{id}=1; Max-Age=86400; Path=/; HttpOnly; SameSite=Lax is set in the response. The counter will not increment again for this browser within 24 hours.
6
Cron flush (every 30 min) β€” the scheduled handler lists all v: keys, deletes each key, then increments D1 by the buffered count. If D1 fails, the key is restored so the next cron cycle retries.

Deduplication Strategies

Two patterns are used. The main tools route stores all seen slugs in one cookie; the domain-specific routers and the view-ping endpoint use one cookie key per tool.

Pattern A β€” Multi-slug cookie (Tools main GET + Blogs + Tutorials)
// Cookie: "viewed_tools" (or "viewed_blogs")
// Value:  "slug-one,slug-two,slug-three"  (comma-separated, max 200 entries)

const raw    = getCookie(c, "viewed_tools") ?? "";
const viewed = new Set(raw ? raw.split(",") : []);
const isNew  = !viewed.has(slug);

// After fetching:
if (isNew) {
  viewed.add(slug);
  setCookie(c, "viewed_tools", [...viewed].slice(-200).join(","), {
    maxAge: 60 * 60 * 24,   // 24 hours
    httpOnly: true,
    sameSite: "Lax",
    path: "/",
  });
}
Pattern B β€” Per-slug cookie key (Lawyer + Real Estate GET routers)
// Cookie key: "viewed_tool_{slug}" β€” one cookie per visited tool
// ⚠️  Must use exact-match parse β€” .includes() causes false positives
//     e.g. "viewed_tool_ai" matches inside "viewed_tool_ai-tools"

const viewedKey   = `viewed_tool_${slug}`;
const rawCookie   = c.req.header("cookie") ?? "";
const cookieParts = rawCookie.split(";").map((p) => p.trim());
const alreadySeen = cookieParts.some(
  (p) => p === viewedKey || p.startsWith(`${viewedKey}=`)
);

// After fetching:
if (!alreadySeen) {
  c.header("Set-Cookie", `${viewedKey}=1; Max-Age=86400; Path=/; HttpOnly; SameSite=Lax`);
}
Pattern B β€” View ping endpoint (POST /api/tools/:id/view)
// Same exact-match parse as Pattern B above
// Uses tool ID (not slug) because cached pages have the ID available client-side

const viewedKey   = `viewed_tool_${id}`;
const rawCookie   = c.req.header("cookie") ?? "";
const cookieParts = rawCookie.split(";").map((p) => p.trim());
const alreadySeen = cookieParts.some(
  (p) => p === viewedKey || p.startsWith(`${viewedKey}=`)
);

if (alreadySeen) return c.json({ success: true, counted: false });

// Increment KV then set cookie...
⚠️ Both patterns are cookie-based and only deduplicate within a browser session / device. Incognito windows, clearing cookies, or different devices will count as new views. This is intentional β€” the overhead of server-side deduplication (per-user DB rows) is not worth it for a view counter.
⚠️ Same-domain requirement: Cookies use SameSite=Lax. The frontend must call the view/click endpoints through the same domain (e.g. a proxy route). Calling the raw workers.dev URL from a different origin causes the browser to silently drop the dedup cookie β€” every request will have an empty cookie header and every page load will be counted as a new view.

Real-Time Count Merging

Because views and clicks are buffered in KV and only synced to D1 every 30 minutes, the D1 counts are always slightly behind. When serving a tool detail page the API merges both sources to return a real-time total.

Formula:
realTimeViews = D1.viewCount + KV buffer (v:{toolId})
realTimeClicks = D1.clickCount + KV buffer (c:{toolId})

Applied in getToolBySlug() and getToolStats() β€” both call getKVCountsForTool(toolId, env.COUNTERS) and add the buffered delta to the D1 values before returning the response.
getToolBySlug β€” KV merge (tool.service.ts)
// After fetching the tool from D1...
const base = deserializeTool(tool);

if (env.COUNTERS) {
  const { kvViews, kvClicks } = await getKVCountsForTool(tool.id, env.COUNTERS);
  if (kvViews  > 0) base.viewCount  = (tool.viewCount  ?? 0) + kvViews;
  if (kvClicks > 0) base.clickCount = (tool.clickCount ?? 0) + kvClicks;
}

return base; // counts reflect everything recorded since last cron sync

Cron Sync β€” KV β†’ D1

The Cloudflare Cron Trigger defined in wrangler.toml fires the scheduled() handler in src/index.ts every 30 minutes. It runs two steps in sequence.

wrangler.toml β€” cron schedule
[triggers]
crons = ["*/30 * * * *"]   # every 30 minutes
                            # change to "0 * * * *" for hourly, "*/15 * * * *" for every 15 min
1
syncCountersToDb(env) β€” lists all v: and c: keys in the COUNTERS KV namespace. For each key: reads the buffered count, deletes the key, then runs prisma.tool.update({ viewCount: { increment: count } }). If the D1 update fails, the key is restored so no counts are lost.
2
recalculateAllRankings(env) β€” after counters are flushed, re-computes rankScore for every non-archived tool using the updated D1 values. Runs in batches of 100 to stay within D1 limits.
Cron stepFunctionSource fileOn failure
Counter flushsyncCountersToDb()counters/counter.service.tsKV key restored; next cron retries
Ranking recalcrecalculateAllRankings()tools/tool.service.tsLogged; rankings remain at last known value
ℹ️ To test the cron locally: wrangler dev --test-scheduled then curl "http://localhost:8787/cdn-cgi/handler/scheduled". In production you can trigger it once from the Cloudflare dashboard under Workers > Triggers.

View Count Endpoints

POST

Client-Side View Ping (primary)

β–Ά
POST /api/tools/:id/view

The primary view tracking endpoint. Called by the frontend after a cached tool page loads β€” the backend GET route is never reached on CDN-cached pages so this ping is the only way to record the view. No request body required. No auth required.

Uses Pattern B cookie dedup (per-tool, exact-match parse). On a new view, increments v:{toolId} in Cloudflare KV. Falls back to a direct D1 write if KV is not bound (local dev).

Path Parameters
ParamTypeDescription
idstringTool ID (not slug)
Cookie
NameValueTTL
viewed_tool_{id}124 hours (Max-Age=86400)
Side effects
StorageAction
Cloudflare KV β€” v:{id}+1 (buffered; flushed to D1 by cron)
D1 Tool.viewCountUpdated on next cron sync (every 30 min)
rankScoreRecalculated on cron sync after D1 update
Response
200 β€” new view counted
{ "success": true, "counted": true }
200 β€” already seen (cookie present)
{ "success": true, "counted": false }
ℹ️ The frontend must call this endpoint on the same domain as the API (e.g. via a proxy route like /api/tools/:id/view on the same origin). This ensures SameSite=Lax cookies are correctly sent and saved by the browser. Calling a different domain will cause the dedup cookie to be silently dropped, counting every page load as a new view.
Frontend example
JavaScript β€” Next.js / Astro page
// Call after page mount β€” fire-and-forget
// Must go through the same domain (proxied) β€” not the raw workers.dev URL
fetch(`/api/tools/${toolId}/view`, {
  method: "POST",
});
GET

Tools β€” View Count (server-side, non-cached only)

β–Ά
GET /api/tools/slug/:slug

Fetches a published tool by slug. Uses Pattern A (multi-slug viewed_tools cookie). On a new view, increments viewCount and triggers updateRankScore() β€” both fire-and-forget so the response is never delayed.

Note: This endpoint is only called when the page is not CDN-cached (e.g., first request after a deploy, or during local development). For production cached pages, the view is tracked by POST /:id/view instead.

The response merges D1 counts with the KV buffer so viewCount and clickCount are always real-time totals.

Cookie
NameValue formatTTLMax entries
viewed_toolsComma-separated slugs24 hours200 slugs (oldest evicted)
Side effects
FieldAction
viewCount+1 directly to D1 (fire-and-forget)
rankScoreRecalculated after viewCount update
GET

Lawyer Tools β€” View Count

β–Ά
GET /api/ai-for-lawyers/:slug

Fetches a lawyer tool by slug from the shared Tool table (guards toolType === "lawyer"). Uses Pattern B (per-slug cookie key, exact-match parse). Delegates to the same getToolBySlug() service as the main tools route, so view tracking and rank score update behavior is identical.

Cookie
NameValueTTL
viewed_tool_{slug}124 hours (Max-Age=86400)
Side effects
FieldAction
viewCount+1 directly to D1 (fire-and-forget)
rankScoreRecalculated after viewCount update
GET

Real Estate Tools β€” View Count

β–Ά
GET /api/ai-for-real-estate/:slug

Identical to the Lawyer route in mechanism. Guards toolType === "real-estate". Uses Pattern B (exact-match cookie parse). Delegates to getToolBySlug().

Cookie
NameValueTTL
viewed_tool_{slug}124 hours
Side effects
FieldAction
viewCount+1 directly to D1 (fire-and-forget)
rankScoreRecalculated after viewCount update
GET

Blog β€” View Count

β–Ά
GET /api/blogs/:slug

Fetches a published blog post. Uses Pattern A (multi-slug viewed_blogs cookie). Increments viewCount directly in D1 (fire-and-forget). No KV buffering. No rank score is computed for blogs β€” only viewCount, helpfulCount, and notHelpfulCount are tracked.

Cookie
NameValue formatTTLMax entries
viewed_blogsComma-separated slugs24 hours200 slugs
Side effects
FieldAction
viewCount+1 directly to D1 (fire-and-forget)
rankScore❌ Not implemented for blogs
GET

Tutorials β€” View Count

β–Ά
GET /api/tutorials/:slug

Fetches a published tutorial. Same pattern as blogs β€” multi-slug cookie, fire-and-forget viewCount increment directly to D1, no KV buffering, no rank score.

Side effects
FieldAction
viewCount+1 directly to D1 (fire-and-forget)
rankScore❌ Not implemented for tutorials

Click Count Endpoint

POST

Track Tool Click

β–Ά
POST /api/tools/:id/click

Increments clickCount on a Tool when the user clicks through to the tool's external website. No auth required. No request body needed. Should be called from the frontend at the moment of outbound navigation β€” e.g., on the "Visit Website" button click.

This endpoint covers all tool types β€” lawyer, real estate, and video tools all use the shared Tool table, so the same endpoint works for all of them. There is no separate /click route on the domain-specific routers.

If the COUNTERS KV namespace is bound, the click is buffered in KV (c:{toolId}) and flushed to D1 by the cron job. Otherwise it writes directly to D1.

Path Parameters
ParamTypeDescription
idstringTool ID (not slug)
Cookie Deduplication

Uses the same exact-match per-key cookie pattern as view tracking. One click is counted per browser per tool per hour. The TTL is shorter than views (1 h vs 24 h) β€” returning to a tool and clicking through again later in the day is legitimate, but spam-clicking in the same session is not.

Cookie nameValueTTL
clicked_tool_{id}11 hour (Max-Age=3600)
Side effects
StorageAction
Cloudflare KV β€” c:{id}+1 (buffered; flushed to D1 by cron)
D1 Tool.clickCountUpdated on next cron sync (every 30 min)
rankScoreRecalculated on cron sync after D1 update
Response
200 β€” click counted
{ "success": true, "counted": true }
200 β€” already clicked within 1 hour (cookie present)
{ "success": true, "counted": false }
404 β€” Tool not found
{ "success": false, "message": "Tool not found." }
ℹ️ Cookie dedup is browser-scoped. Multiple devices or incognito windows each get their own 1-hour window. For stricter dedup (e.g. IP-based rate limiting) apply it at the Cloudflare WAF / Rate Limiting layer β€” no code change needed.

Rank Score β€” Formula

Tools only. The rank score is a single float stored on the Tool row and kept in sync after every engagement event. It is the primary sort key for the homepage and listing endpoints.

rankScore = upvoteCount Γ— 4.0 β€” strongest community signal
+ clickCount Γ— 2.5 β€” strong outbound-intent signal
+ viewCount Γ— 0.05 β€” dampened to prevent gaming
+ rating Γ— reviewCount Γ— 3.0 β€” quality weighted by credibility
+ 50 β€” if featured (editorial spotlight)
+ 25 β€” if verified (quality badge)
+ 30 β€” if trending (editorial flag)
+ 30 / (daysSincePublished + 1) β€” recency boost, decays from ~30 β†’ ~0
ℹ️ The recency boost starts at ~30 on day 0 and naturally decays. A new tool with zero engagement still ranks ahead of older tools for its first few days. This prevents new listings from being permanently buried.

When Rank Score Updates

For KV-buffered events (view ping, click), the rank score updates on the cron flush rather than immediately. For all other events it recalculates in the background after the action.

Trigger eventEndpointrankScore updated?When
View ping (client-side)POST /api/tools/:id/view⚠️ KV bufferedOn cron flush (every 30 min)
Page view (GET, non-cached)GET /api/tools/slug/:slugβœ… YesFire-and-forget after D1 write
Page view (GET, non-cached)GET /api/ai-for-lawyers/:slugβœ… YesFire-and-forget after D1 write
Page view (GET, non-cached)GET /api/ai-for-real-estate/:slugβœ… YesFire-and-forget after D1 write
Outbound clickPOST /api/tools/:id/click⚠️ KV bufferedOn cron flush (every 30 min)
Upvote togglePOST /api/tools/:id/upvoteβœ… YesAwaited
Review submittedPOST /api/tools/:id/reviewsβœ… YesAwaited
Rating submittedPOST /api/tools/:id/rateβœ… YesAwaited
Tool created / status changedPOST/PATCH /api/toolsβœ… YesAwaited
Admin bulk recalculatePOST /api/tools/admin/recalculate-rankingsβœ… Yes β€” all toolsAwaited (batched)
Cron syncScheduled handler (every 30 min)βœ… Yes β€” all toolsAfter counter flush
⚠️ Blog and tutorial view counts do not trigger a rank score update β€” those resource types do not have a rankScore field and are sorted purely by publishedAt, viewCount, or helpfulCount at query time.