Real-time sales pipeline intelligence. A live event stream, a drag-and-drop pipeline, revenue forecasting and team leaderboards — running on a real backend with a reproducible dataset.
Leadscope is a sales-operations dashboard. It answers the four questions a revenue team asks every morning:
- What is happening right now? A server-sent event stream pushes every touch, stage change and closed deal to every connected client as it happens.
- Where is the money? An open pipeline broken out by stage, industry and channel, with a least-squares projection and an honest confidence band.
- What is stuck? Open deals that have not moved in two weeks, biggest first.
- Who is delivering? Team ranking by revenue, deals, activity or quota attainment, with movement against the previous window.
Everything runs locally against a seeded SQLite database. There is no external service to sign up for and no API key to obtain: clone, install, seed, run.
git clone https://github.com/paliibo/leadscope.git && cd leadscope
pnpm install
cp .env.example .env
pnpm db:setup # migrate + seed ~2,400 leads and ~23k activities
pnpm devThen open http://localhost:3000 and sign in with demo@leadscope.app / demo1234.
The repository began as a front-end take-home for LinkMe: a single page that fetched two fixed JSON blobs from jsonbin.io and sorted them in the browser with a hand-rolled quicksort. It did the job it was set.
This is that exercise rebuilt as a product — a real schema, a real API, a real event pipeline, and the tests to keep it honest. The brand DNA survived (the purple-to-blue gradient, the card treatment, the Graphik wordmark); nothing else did.
A PipelineSimulator walks the demo pipeline forward — logging touches, advancing
stages, landing new leads — writing each action to the database and publishing it to
an in-process event bus. /api/stream serves that bus over SSE with a replay buffer, so
a client that reconnects with Last-Event-ID resumes rather than leaving a hole in the
feed. In production this is exactly where a CRM webhook would plug in; nothing
downstream can tell the difference.
Deals move between stages with @dnd-kit. The write is optimistic — the card lands
where it was dropped immediately and rolls back with a toast if the request fails — and
the change is broadcast to everyone else watching the same board.
Closed-won revenue is projected with ordinary least squares and a 95% prediction interval that widens with distance from the sample mean. The card reports the fit quality next to the projection, and says "noisy" out loud below R² 0.15 — daily revenue from a fourteen-person team is lumpy, and a forecast that hides that is a lie.
| Conversion funnel | Built from each lead's furthest reached stage, so a lost deal still counts for every step it cleared. Flags its own worst drop-off. |
| Pipeline velocity | Median and mean dwell time per stage, sales-cycle length, and stalled deals ranked by value at risk. |
| Lead scoring | Five weighted components, returned with the breakdown so a rep can see why a lead scored 61. |
| Leaderboard | Competition ranking (1, 2, 2, 4), quota rings, and movement against the previous window. |
| Command palette | ⌘K / Ctrl-K. Debounced server-side lead search, navigation, theme. |
| Keyboard navigation | g then o/p/l/b/a, suppressed while focus is in an input. |
| CSV export | Honours every active filter, RFC 4180 quoted. |
| Themes | Light and dark from one set of CSS custom properties; charts repaint on the switch. |
| Auth | scrypt password hashing, HS256 session cookies, edge middleware, role-scoped queries. |
Browser Next.js server SQLite / libSQL
──────────────────────────── ───────────────────────────── ─────────────────
React Query cache ◄──── JSON ─── Route handlers ──── Drizzle ────► leads
▲ (zod-validated) activities
│ │ accounts
│ invalidate │ publish reps · teams · goals
│ ▼ ▲
useLiveStream ◄──── SSE ────── EventBus ◄──── writes ─── PipelineSimulator
(EventSource) (replay buffer)
Three things worth calling out:
The simulator writes through the same path as a user. It does not fabricate events for the UI — it mutates the database and then announces it. That means a page refresh and the live ticker can never disagree, which is the failure mode every fake "realtime demo" has.
Events are coalesced before they hit the cache. A burst of activity would otherwise
fire a refetch per event. LiveProvider batches invalidations into one per query key
per 1.2 s window.
Reps are scoped at the route boundary, not in the UI. canViewAllReps() decides
whether an ownerId parameter is honoured or overridden with the caller's own id, so a
rep cannot widen their view by editing a query string.
More detail in docs/architecture.md, and the reasoning behind
the bigger calls in docs/decisions.md.
| Choice | Reason |
|---|---|
| Next.js 15, App Router | Server components for the shell and session, route handlers for the API — one deployable, one language. |
| Drizzle + libSQL | Real SQL with real types and checked-in migrations. A file locally, a Turso URL in production, no code change. |
| SSE, not WebSockets | Traffic is strictly server → client. SSE survives proxies that mangle upgrades and the browser reconnects on its own, with Last-Event-ID for free. |
| TanStack Query | The live stream pushes; the cache is the thing being pushed to. Optimistic updates with rollback come with it. |
| Chart.js | Already proven in the original exercise. Canvas rendering handles a 365-point series without the DOM cost of an SVG chart library. |
| Tailwind + CSS variables | Themes are a token swap, not a second stylesheet. |
| Vitest + Playwright | Vitest for the analytics maths, Playwright for the flows a unit test cannot reach — drag-and-drop, SSE, CSV download. |
Deliberately not used: no Redux (server state is a cache, not app state), no component library (the design language came from the original exercise), no ORM-free raw SQL (migrations and types earn their keep).
The demo dataset is generated, not fixtured — SEED=20260101 produces the same 14
months every time, on every machine.
| Leads | ~2,400 across 7 stages |
| Activities | ~23,000 touches, stage changes and closes |
| Accounts | 140 companies across 9 industries |
| Reps | 14 across 4 regional teams, with monthly quotas |
| Win rate | ~20% of closed deals |
| Median cycle | ~42 days, creation to close |
Each lead walks the pipeline stage by stage, with advance odds modified by rep skill and channel quality and log-normal dwell times between transitions. Roughly a third of deals that fail to advance go quiet rather than dying, and most of those are swept up in a hygiene pass 35–95 days later. Arrivals follow a growth trend with weekday and seasonal dips.
That detail is not decoration. Without it the charts read as noise around a flat line, the funnel converts uniformly, and the velocity report has nothing to find.
pnpm db:stats # pipeline by stage, revenue by month, channel win rates
pnpm db:reset # wipe and regenerate| Command | What it does |
|---|---|
pnpm dev |
Dev server on :3000 |
pnpm db:setup |
Apply migrations, then seed |
pnpm db:reset |
Delete the local database and rebuild it |
pnpm db:generate |
Generate a migration from a schema change |
pnpm db:studio |
Drizzle Studio |
pnpm db:stats |
Print a health summary of the current data |
pnpm test |
Unit tests |
pnpm test:coverage |
Unit tests with coverage thresholds |
pnpm e2e |
Playwright, desktop and mobile |
pnpm verify |
Types, lint and unit tests — what CI runs |
pnpm screenshots |
Regenerate the images in this README |
194 unit tests cover the code where a wrong answer is silent: regression and forecasting, funnel maths, dwell times, ranking, scoring, the event bus, password hashing and session verification, and every request schema. They assert properties, not snapshots — funnel counts never increase down the funnel, no feature combination pushes a score outside 0–100, a tie in the leaderboard breaks the same way regardless of input order.
24 end-to-end tests cover what a unit test cannot: the drag actually persists across a reload, the SSE stream actually connects, the CSV actually matches the active filters, and no page scrolls sideways at 375px.
Both run in CI on every push, alongside a production build.
Docker
docker compose up --buildMulti-stage, non-root, traced standalone output, health-checked against /api/health,
with the database on a named volume.
Vercel or any Node host
Point DATABASE_URL at a Turso database, set DATABASE_AUTH_TOKEN
and a real AUTH_SECRET, and deploy. Nothing in the data layer changes — libSQL speaks
to a file and to a remote database through the same client.
Set LIVE_SIMULATOR=0 in any environment where you do not want the demo pipeline
moving on its own.
src/
├── app/
│ ├── (dashboard)/ overview · pipeline · leads · leaderboard · analytics
│ ├── api/ route handlers, including the SSE stream
│ └── login/
├── components/
│ ├── ui/ primitives: card, button, sparkline, stat tile
│ ├── charts/ themed Chart.js wrappers
│ ├── layout/ shell, sidebar, command palette, live ticker
│ └── {overview,pipeline,leads,leaderboard,analytics}/
├── db/
│ ├── schema.ts six tables, indexed for the queries that exist
│ └── queries/ every SQL query in the app
├── lib/
│ ├── analytics/ forecast · funnel · velocity · scoring · ranking
│ ├── auth/ scrypt hashing, JWT sessions
│ ├── demo/ the deterministic dataset generator
│ ├── events/ event bus and pipeline simulator
│ └── validation/ zod schemas for every request shape
└── middleware.ts edge session gate
MIT — see LICENSE.

