# SEO Command Centre

*Dashboards report that three pages are losing traffic and rarely say which three. This one names them.*

**Category:** WEB PLATFORM / SEO ANALYTICS
**Status:** in progress
**Period:** September 2026
**Built by:** HM Ammar, Digital Architect & Head of IT, Kuala Lumpur

**Stack:** React, TypeScript, Vite, Recharts, SheetJS, IndexedDB, Cloudflare Workers, Search Console API

## The challenge

SEO tools report numbers. They rarely name the things behind them. A dashboard says three pages are losing traffic and never says which three. The definition is the harder problem: rank keywords by clicks and you reward search volume, rank them by position and you ignore whether the ranking earns anything. An agency running several sites needs a list of what to fix next, not another chart.

## What I built

I built a React and TypeScript dashboard that reads the exports an agency already produces, including Search Console, Semrush, Ahrefs, Screaming Frog and hand-written audit workbooks. I score every keyword against what its own position, intent and brand status should earn. Eleven screens cover the command centre, search terms, pages, technical, links, markets, competitors, the action plan, the portfolio, import and connections. Every sentence that counts something carries the named items behind it as buttons that open the underlying rows. I wrote and tested a Cloudflare Worker backend alongside it, holding one OAuth application on behalf of all customers, so nobody has to create their own Google Cloud project.

## The detail

### Defining better

I measure each keyword against a blended position-CTR curve, interpolated geometrically rather than linearly because click-through decays multiplicatively. I adjust the expectation for intent and brand, reduce it where AI overviews and shopping carousels sit above the organic results, and it can be recalibrated against the site's own data. Five sub-scores combine into a 0-100 figure that I shrink toward the middle in proportion to how little data supports it, so a keyword with eleven impressions can never top a list.

### Named evidence

One component renders every counted claim, turning each item it counts into a button that opens a drawer of the actual rows. I build the sentence from the rows, so the number and the things behind it cannot drift apart. Every chart also has a table view, so no value is gated behind a hover.

### Arithmetic guards

A keyword contributes only its single best finding to the headline recoverable-clicks figure, never the sum of them, and I cap the total at a share of what the site could theoretically earn. Without both, a long findings list adds up to more clicks than there are searches. Estimates show their inputs and their assumed success rates, and I have the interface say plainly that those rates are practitioner heuristics rather than measurements.

### Real exports

Audit summary tabs are not tables. They are titled blocks stacked vertically, each with its own header row, blank separators and trailing comment columns belonging to no header. My parser splits a sheet into blocks, classifies each one with a confidence score, then reports tab by tab what it understood and what it discarded, reading a broken formula cell as missing and never as zero. I also encoded the column traps that produce confidently wrong advice: Semrush Traffic is a model and never enters a clicks series, competition is paid density on a 0-1 scale while difficulty is organic on 0-100, "Nofollow: TRUE" and "Follow: TRUE" map to opposite fields, and CTR is always recomputed from clicks over impressions.

### Auth by type

The hosted backend would hold other companies' Google refresh tokens, so I typed the route table rather than leave it conventional: a route is session-protected unless it declares public, and a protected handler is handed a verified session object it cannot manufacture. Forgetting the check fails to compile instead of opening an endpoint. I encrypt refresh tokens at rest with AES-GCM, and sessions are HMAC-signed __Host- cookies with no sessions table, so a request costs no database read.

## The outcome

It runs locally against a bundled example workspace, and the frontend builds clean. My two test suites pass 286 tests between them: 99 in the dashboard covering the scoring maths, ingestion and the Search Console client, and 187 in the Worker covering auth, sessions, the Google client and sync. I have written, tested and documented the multi-tenant backend end to end, but I have never deployed it. Its config still carries placeholder origins and an unset database id, and the dashboard still talks to the local companion rather than a hosted API. What is left is a Google OAuth application and a Cloudflare account, not code.

---

[All work](https://hmammar.world/) · [HTML version](https://hmammar.world/work/seo-command-centre.html)
