Traffic, as text
diff --git a/app/api/tracker/v1/stats/route.ts b/app/api/tracker/v1/stats/route.ts
index d862a5a..3ffed05 100644
--- a/app/api/tracker/v1/stats/route.ts
+++ b/app/api/tracker/v1/stats/route.ts
@@ -18,6 +18,11 @@ import { DEFAULT_WHO, parseWho, WHO_PARAM, whoToKind } from "@/lib/tracker/who";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
+/** Anything but an affirmative is off, so a typo cannot buy an extra query. */
+export function parseDetail(raw: string | null): boolean {
+ return raw === "1" || raw === "true" || raw === "yes";
+}
+
export async function GET(req: NextRequest) {
const auth = await authenticateBearer(req);
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
@@ -36,7 +41,13 @@ export async function GET(req: NextRequest) {
const resolved = await resolveProject(sb, auth.userId, sp.get("site"));
if (!resolved.ok) return NextResponse.json({ error: resolved.error }, { status: resolved.status });
+ // `detail=1` adds the series and the unfiltered human / bot mix, which is
+ // what the dashboard's per-domain screen and its risk-to-viral score are
+ // built from. Off by default: `crawlproof stats` prints neither, and a
+ // fleet-wide fan-out should not pay for a panel nobody renders.
+ const detail = parseDetail(sp.get("detail"));
+
const range = trackerRange(sp.get("range"));
- const stats = await projectStats(sb, resolved.project, range, whoToKind(who), who);
+ const stats = await projectStats(sb, resolved.project, range, whoToKind(who), who, detail);
return NextResponse.json(stats);
}
diff --git a/cli/dashboard.ts b/cli/dashboard.ts
index 54a1b64..7d76d5e 100644
--- a/cli/dashboard.ts
+++ b/cli/dashboard.ts
@@ -9,12 +9,18 @@
import type { Container, RenderArgs, Theme } from "@profullstack/hqtui";
-import { collectDashboard, type CoinPayAuth, type DashboardSnapshot } from "../lib/dashboard/collect";
+import { collectDashboard, type CoinPayAuth, type DashboardSnapshot, type SiteStats } from "../lib/dashboard/collect";
import { AD_TARGET_CTR, AD_TARGET_IMPRESSIONS, adTargets } from "../lib/dashboard/roi";
+import { buildSiteDetail, type SiteDetail } from "../lib/dashboard/site";
+import type { Component } from "../lib/dashboard/score";
export const TABS = ["ROI", "Traffic", "Ads", "Money", "Spend"] as const;
export const RANGES = ["1h", "4h", "1d", "1w", "1m"] as const;
+/** How the Traffic list is ordered. `s` cycles it. */
+export const SORTS = ["score", "visitors", "pageviews"] as const;
+export type Sort = (typeof SORTS)[number];
+
/** Which tracker range pairs with which CoinPay window. */
export const FINANCE_DAYS: Record = {
"1h": 7,
@@ -75,7 +81,7 @@ const clock = () => new Date().toLocaleTimeString("en-US", { hour12: false });
type Pane = { selected: number; offset: number; total: number };
-type State = {
+export type State = {
tab: number;
range: string;
who: string;
@@ -88,6 +94,15 @@ type State = {
panes: Record;
targetImpressions: number;
targetCtr: number;
+ /**
+ * The property the Traffic screen has drilled into, by name.
+ *
+ * By name rather than by index, because the list reorders on every refresh
+ * and on every sort — an index would silently open a different domain the
+ * moment anything moved.
+ */
+ domain: string | null;
+ sort: Sort;
};
function pane(state: State, name: string, total: number): Pane {
@@ -295,47 +310,123 @@ function roiScreen(ui: Container, state: State, theme: Theme): void {
});
}
+/**
+ * The properties, in whatever order was asked for.
+ *
+ * Sites that did not answer sort last whatever the key, because a site with no
+ * numbers is not a site with low numbers; and a null score sorts below a real
+ * one rather than above it, which is what `?? 0` would have done.
+ */
+export function sortSites(sites: SiteStats[], sort: Sort): SiteStats[] {
+ const key = (row: SiteStats): number => {
+ if (sort === "visitors") return num(row.visitors);
+ if (sort === "pageviews") return num(row.pageviews);
+ return row.score?.score ?? -1;
+ };
+ return [...sites].sort((a, b) => {
+ if (Boolean(a.error) !== Boolean(b.error)) return a.error ? 1 : -1;
+ return key(b) - key(a) || a.site.localeCompare(b.site);
+ });
+}
+
+type ThemeColor = Theme["success"];
+
+/** Score bands, so a glance at the column means something without reading it. */
+export function scoreColor(theme: Theme, score: number | null | undefined): ThemeColor | undefined {
+ if (score === null || score === undefined) return theme.muted;
+ if (score >= 60) return theme.success;
+ if (score >= 35) return theme.primary;
+ if (score >= 15) return theme.warning;
+ return theme.muted;
+}
+
+export function scoreText(row: SiteStats): string {
+ const value = row.score?.score;
+ if (value === null || value === undefined) return "—";
+ // A trailing ~ marks a score from too small a sample to lean on. The number
+ // is still shown: hiding it would only move the guess into someone's head.
+ return `${value.toFixed(0)}${row.score?.provisional ? "~" : ""}`;
+}
+
function trafficScreen(ui: Container, state: State, theme: Theme): void {
const s = state.snapshot as DashboardSnapshot;
- const rows = s.sites;
+ const rows = sortSites(s.sites, state.sort);
ui.grid({ columns: ["3fr", "2fr"], gap: 1 }, (grid) => {
grid.panel(
{
title: `Sites · ${state.range} · ${state.who}`,
- subtitle: `${s.roi.attention.sitesReporting} of ${rows.length} reporting`,
- footer: "j/k scroll",
+ subtitle: `${s.roi.attention.sitesReporting} of ${rows.length} reporting · by ${state.sort}`,
+ footer: "↑/↓ select · Enter opens · s sorts",
},
(p) => {
const view = pane(state, "sites", rows.length);
- p.table({
+ /** Absolute row indexes the table drew this frame, in screen order. */
+ const drawn: number[] = [];
+ type SiteRow = {
+ site: string;
+ score: string;
+ scoreValue: number | null;
+ visitors: string;
+ pageviews: string;
+ cost: string;
+ note: string;
+ };
+ p.table({
columns: [
{
key: "site",
title: "Site",
- width: 28,
+ width: 26,
// A site that did not answer is coloured, not silently ordinary.
- color: (row: { note?: string }) => (row.note ? theme.danger : undefined),
+ color: (row: SiteRow) => (row.note ? theme.danger : undefined),
+ },
+ {
+ key: "score",
+ title: "Score",
+ align: "right",
+ width: 6,
+ color: (row: SiteRow) => scoreColor(theme, row.scoreValue),
},
{ key: "visitors", title: "Visitors", align: "right", width: 10 },
{ key: "pageviews", title: "Views", align: "right", width: 9 },
{ key: "cost", title: "Cost", align: "right", width: 10 },
- { key: "note", title: "", width: 16, color: theme.danger },
+ { key: "note", title: "", width: 14, color: theme.danger },
],
rows: rows.map((row) => {
const share = s.roi.attention.visitors > 0 ? row.visitors / s.roi.attention.visitors : 0;
return {
site: row.site,
+ score: row.error ? "—" : scoreText(row),
+ scoreValue: row.error ? null : (row.score?.score ?? null),
visitors: row.error ? "—" : count(row.visitors),
pageviews: row.error ? "—" : count(row.pageviews),
cost: row.error ? "—" : money(s.roi.cost.windowUsd * share, { cents: true }),
- note: row.error ? row.error.slice(0, 16) : "",
+ note: row.error ? row.error.slice(0, 14) : "",
};
}),
offset: view.offset,
selected: view.selected,
+ // The table scrolls itself to keep the selection visible, which means
+ // it — not this — knows where the window actually starts. `onRow`
+ // reports that back, both so the stored offset stays truthful and so
+ // a click can be mapped to a row rather than to an assumption.
+ followSelection: true,
scrollbar: true,
+ onRow: (_row: SiteRow, index: number) => {
+ if (!drawn.length) view.offset = index;
+ drawn.push(index);
+ },
onScroll: (delta: number) => scrollPane(view, delta, 3),
+ // A click is a selection and an open, the way a click on a row in any
+ // other list is. Row 0 is the first body row; the header only focuses.
+ onSelectRow: (visibleRow: number) => {
+ const index = drawn[visibleRow];
+ const row = index === undefined ? undefined : rows[index];
+ if (!row || index === undefined) return;
+ view.selected = index;
+ state.domain = row.site;
+ },
});
},
);
@@ -375,6 +466,214 @@ function trafficScreen(ui: Container, state: State, theme: Theme): void {
});
}
+/** The site the Traffic screen has opened, if it is still in the snapshot. */
+export function selectedSite(state: State): SiteStats | null {
+ if (!state.domain || !state.snapshot) return null;
+ return state.snapshot.sites.find((s) => s.site === state.domain) ?? null;
+}
+
+/** Rebuild one property's whole picture from the snapshot already in hand. */
+export function detailFor(state: State): SiteDetail | null {
+ const site = selectedSite(state);
+ const s = state.snapshot;
+ if (!site || !s) return null;
+ return buildSiteDetail({
+ site,
+ roi: s.roi,
+ ads: s.ads,
+ finance: s.finance,
+ window: { range: s.window.range, who: s.window.who, financeDays: s.window.financeDays },
+ });
+}
+
+type ComponentRow = { part: string; value: string; why: string; color: ThemeColor | undefined };
+
+const componentRows = (components: Component[], theme: Theme): ComponentRow[] =>
+ components.map((c) => ({
+ part: `${c.label} ·${(c.weight * 100).toFixed(0)}`,
+ value: c.value === null ? "—" : pct(c.value, 0),
+ why: c.detail,
+ color: c.value === null ? theme.muted : undefined,
+ }));
+
+/**
+ * One property, on its own: what arrived, what it cost and earned, and why it
+ * scores what it scores.
+ *
+ * Every figure comes from the snapshot the list was drawn from, so opening a
+ * domain cannot show a number the row behind it disagreed with, and Esc goes
+ * back to exactly the list that was there.
+ */
+function domainScreen(ui: Container, state: State, theme: Theme): void {
+ const detail = detailFor(state);
+ if (!detail) {
+ ui.panel({ title: state.domain ?? "Site" }, (p) => {
+ p.text("That site is not in the current snapshot.", { fg: theme.warning });
+ p.text("Esc goes back to the list.", { fg: theme.muted });
+ });
+ return;
+ }
+
+ const t = detail.traffic;
+ const m = detail.money;
+ const score = detail.score;
+
+ ui.grid({ columns: ["1fr", "1fr", "1fr"], rows: [12, "1fr"], gap: 1 }, (grid) => {
+ grid.panel(
+ {
+ title: detail.site,
+ subtitle: `${detail.window.range} · ${detail.window.who}`,
+ titleColor: theme.title,
+ },
+ (p) => {
+ if (detail.error) {
+ p.text(detail.error, { fg: theme.danger });
+ p.text("Its numbers are missing, not zero.", { fg: theme.muted });
+ return;
+ }
+ p.keyValues(
+ [
+ { label: "Pageviews", value: count(t.pageviews), color: theme.primary },
+ { label: "Visits", value: count(t.visitors) },
+ { label: "Humans", value: t.mixKnown ? count(t.humans) : "—", color: theme.success },
+ { label: "Bots", value: t.mixKnown ? count(t.bots) : "—", color: theme.warning },
+ {
+ label: "Human share",
+ value: pct(t.humanShare, 0),
+ color: (t.humanShare ?? 1) < 0.5 ? theme.warning : theme.success,
+ },
+ { label: "AI referrals", value: count(t.aiReferrals) },
+ { label: "", value: "" },
+ { label: "Share of views", value: pct(t.viewShare, 1) },
+ { label: "Share of visits", value: pct(t.visitShare, 1) },
+ ],
+ { labelWidth: 15 },
+ );
+ const line = t.series.map((point) => num(point.humans));
+ if (line.length > 1) {
+ p.sparkline({ values: line, color: theme.success, label: "humans", text: count(t.humans) });
+ }
+ },
+ );
+
+ // Short subtitles on purpose: hqtui draws the title and the subtitle in the
+ // same border row and the subtitle wins, so a long one costs the panel its
+ // own name at a narrow width.
+ grid.panel({ title: "Money", subtitle: `${detail.window.range} · ${detail.window.financeDays}d bank` }, (p) => {
+ p.keyValues(
+ [
+ {
+ label: "Cost · by views",
+ value: m.costByViewsUsd === null ? "—" : money(m.costByViewsUsd, { cents: true }),
+ color: theme.danger,
+ },
+ {
+ label: "Cost · by visits",
+ value: m.costByVisitsUsd === null ? "—" : money(m.costByVisitsUsd, { cents: true }),
+ color: theme.muted,
+ },
+ {
+ label: "Revenue",
+ value: m.revenueUsd === null ? "—" : money(m.revenueUsd, { cents: true }),
+ color: theme.success,
+ },
+ {
+ label: "Net",
+ value: m.netUsd === null ? "—" : money(m.netUsd, { cents: true }),
+ color: m.netUsd === null ? theme.muted : signed(theme, m.netUsd),
+ },
+ {
+ label: "Per 1k humans",
+ value: m.rpmUsd === null ? "—" : money(m.rpmUsd, { cents: true }),
+ },
+ { label: "", value: "" },
+ // Internal by construction: one account owns the slot and the
+ // campaign, so this is the same dollar in two pockets.
+ { label: "Ad earned (int.)", value: money(m.adEarnedUsd, { cents: true }), color: theme.muted },
+ { label: "Ad spent (int.)", value: money(m.adSpentUsd, { cents: true }), color: theme.muted },
+ { label: "Impressions", value: count(m.adImpressions), color: theme.muted },
+ ],
+ { labelWidth: 17 },
+ );
+ });
+
+ grid.panel(
+ {
+ title: "Risk-to-viral",
+ subtitle: score.provisional ? "provisional" : `${pct(score.coverage, 0)} scored`,
+ subtitleColor: score.provisional ? theme.warning : theme.muted,
+ },
+ (p) => {
+ p.text(score.score === null ? " —" : ` ${score.score.toFixed(0)}`, {
+ bold: true,
+ fg: scoreColor(theme, score.score),
+ });
+ p.meters(
+ [
+ { label: "viral", value: score.viral, max: 1, text: pct(score.viral, 0) },
+ { label: "risk", value: score.risk, max: 1, text: pct(score.risk, 0) },
+ ],
+ { labelWidth: 7, valueWidth: 6 },
+ );
+ p.text("100 × viral × (1 − risk/2)", { fg: theme.muted });
+ for (const note of score.notes.slice(0, 2)) p.text(`· ${note}`, { fg: theme.warning, wrap: true });
+ },
+ );
+
+ grid.panel({ title: "Why it scores that", subtitle: "weights", colSpan: 2 }, (p) => {
+ p.table({
+ columns: [
+ { key: "part", title: "Viral", width: 23 },
+ { key: "value", title: "", align: "right", width: 6 },
+ { key: "why", title: "", width: 41, color: theme.muted },
+ ],
+ rows: componentRows(score.viralComponents, theme),
+ rowColor: (row: ComponentRow) => row.color,
+ });
+ p.table({
+ columns: [
+ { key: "part", title: "Risk", width: 23 },
+ { key: "value", title: "", align: "right", width: 6 },
+ { key: "why", title: "", width: 41, color: theme.muted },
+ ],
+ rows: componentRows(score.riskComponents, theme),
+ rowColor: (row: ComponentRow) => row.color,
+ });
+ for (const gap of detail.gaps.slice(0, 3)) p.text(`· ${gap}`, { fg: theme.muted, wrap: true });
+ });
+
+ grid.cell({ gap: 1 }, (col) => {
+ col.panel({ title: "Where they came from" }, (p) => {
+ if (!t.sources.length) {
+ p.text("Nobody arrived in this window.", { fg: theme.muted });
+ return;
+ }
+ const max = Math.max(1, ...t.sources.map((x) => num(x.value)));
+ p.meters(
+ t.sources.slice(0, 6).map((x) => ({
+ label: x.label.slice(0, 20),
+ value: num(x.value),
+ max,
+ text: count(x.value),
+ })),
+ { labelWidth: 21, valueWidth: 7 },
+ );
+ });
+
+ col.panel({ title: "Most-read pages" }, (p) => {
+ if (!t.pages.length) {
+ p.text("No pages read in this window.", { fg: theme.muted });
+ return;
+ }
+ p.keyValues(
+ t.pages.slice(0, 6).map((x) => ({ label: x.label.slice(0, 26), value: count(x.value) })),
+ { labelWidth: 27 },
+ );
+ });
+ });
+ });
+}
+
function adsScreen(ui: Container, state: State, theme: Theme): void {
const s = state.snapshot as DashboardSnapshot;
const ads = s.ads;
@@ -674,6 +973,193 @@ function spendScreen(ui: Container, state: State, theme: Theme): void {
const SCREENS = [roiScreen, trafficScreen, adsScreen, moneyScreen, spendScreen];
+/**
+ * Draw whichever screen the state is on.
+ *
+ * One function rather than an index into SCREENS at the call site, because the
+ * Traffic tab has two screens — the list and one property — and the choice
+ * between them is state, not a tab. Exported so the render tests draw exactly
+ * what the app draws.
+ */
+export function renderBody(ui: Container, state: State, theme: Theme): void {
+ if (!state.snapshot) {
+ ui.panel({ title: "Spend & ROI" }, (p) => {
+ if (state.error) {
+ p.text(`Could not load: ${state.error}`, { fg: theme.danger });
+ p.text("Press r to retry, q to quit.", { fg: theme.muted });
+ } else {
+ p.text("Reading the fleet…", { fg: theme.muted });
+ p.text("One tracker call per site, plus ad earnings and CoinPay.", { fg: theme.muted });
+ }
+ });
+ return;
+ }
+ if (state.tab === 1 && state.domain) {
+ domainScreen(ui, state, theme);
+ return;
+ }
+ (SCREENS[state.tab] ?? roiScreen)(ui, state, theme);
+}
+
+/** A fresh state, for `runDashboard` and for the render tests alike. */
+export function initialState(overrides: Partial = {}): State {
+ return {
+ tab: 0,
+ range: "1d",
+ who: "humans",
+ snapshot: null,
+ loading: false,
+ lastRefresh: null,
+ error: null,
+ paused: false,
+ showHelp: false,
+ panes: {},
+ targetImpressions: AD_TARGET_IMPRESSIONS,
+ targetCtr: AD_TARGET_CTR,
+ domain: null,
+ sort: "score",
+ ...overrides,
+ };
+}
+
+/** Move the highlighted row, letting the table work out the scroll. */
+function moveSelection(p: Pane, delta: number): void {
+ const max = Math.max(0, p.total - 1);
+ p.selected = Math.max(0, Math.min(p.selected + delta, max));
+}
+
+export type KeyLike = { name: string; shift?: boolean };
+
+/**
+ * Every key the dashboard answers to, as a pure function of the state.
+ *
+ * Pulled out of the app so the navigation that matters — opening a domain,
+ * coming back from it, re-sorting without losing your place — can be tested
+ * without a terminal. Returns true when something changed and the frame is
+ * worth redrawing.
+ */
+export function handleKey(state: State, event: KeyLike, actions: { refresh: () => void }): boolean {
+ if (state.showHelp) {
+ state.showHelp = false;
+ return true;
+ }
+
+ const onTraffic = state.tab === 1;
+ const sites = state.snapshot ? sortSites(state.snapshot.sites, state.sort) : [];
+ const view = pane(state, TAB_PANE[state.tab] as string, state.panes[TAB_PANE[state.tab] as string]?.total ?? 0);
+
+ // A number is always the top-level screen it names, so 2 is the way back to
+ // the list from a domain as well as the way to the Traffic tab from anywhere.
+ const digit = Number(event.name);
+ if (Number.isInteger(digit) && event.name.length === 1 && digit >= 1 && digit <= TABS.length) {
+ state.tab = digit - 1;
+ state.domain = null;
+ return true;
+ }
+
+ switch (event.name) {
+ case "escape":
+ case "backspace":
+ if (!state.domain) return false;
+ state.domain = null;
+ return true;
+
+ case "enter":
+ case "return":
+ case "right": {
+ // → opens a domain from the list, and otherwise keeps its old job of
+ // moving to the next screen.
+ if (onTraffic && !state.domain && sites.length) {
+ const row = sites[Math.min(view.selected, sites.length - 1)];
+ if (row) {
+ state.domain = row.site;
+ return true;
+ }
+ }
+ if (event.name !== "right") return false;
+ state.tab = event.shift ? (state.tab + TABS.length - 1) % TABS.length : (state.tab + 1) % TABS.length;
+ return true;
+ }
+
+ case "tab":
+ case "l":
+ state.tab = event.shift ? (state.tab + TABS.length - 1) % TABS.length : (state.tab + 1) % TABS.length;
+ return true;
+
+ case "left":
+ case "h":
+ // ← is the way back out of a domain too, since that is where it came from.
+ if (state.domain) {
+ state.domain = null;
+ return true;
+ }
+ state.tab = (state.tab + TABS.length - 1) % TABS.length;
+ return true;
+
+ case "s": {
+ // Re-sorting keeps the highlight on the same property rather than on the
+ // same row number, which is the only version of this that is not annoying.
+ const held = sites[view.selected]?.site ?? null;
+ state.sort = SORTS[(SORTS.indexOf(state.sort) + 1) % SORTS.length] as Sort;
+ if (held && state.snapshot) {
+ const next = sortSites(state.snapshot.sites, state.sort).findIndex((row) => row.site === held);
+ if (next >= 0) view.selected = next;
+ }
+ return true;
+ }
+
+ case "r":
+ case "f5":
+ actions.refresh();
+ return true;
+
+ case "w":
+ state.range = RANGES[(RANGES.indexOf(state.range as never) + 1) % RANGES.length] as string;
+ actions.refresh();
+ return true;
+
+ case "b":
+ state.who = state.who === "humans" ? "all" : state.who === "all" ? "bots" : "humans";
+ actions.refresh();
+ return true;
+
+ case "p":
+ case "space":
+ state.paused = !state.paused;
+ return true;
+
+ case "?":
+ case "f1":
+ state.showHelp = true;
+ return true;
+
+ case "up":
+ case "k":
+ if (onTraffic && !state.domain) moveSelection(view, -1);
+ else scrollPane(view, -1);
+ return true;
+
+ case "down":
+ case "j":
+ if (onTraffic && !state.domain) moveSelection(view, 1);
+ else scrollPane(view, 1);
+ return true;
+
+ case "pageup":
+ if (onTraffic && !state.domain) moveSelection(view, -10);
+ else scrollPane(view, -1, 10);
+ return true;
+
+ case "pagedown":
+ if (onTraffic && !state.domain) moveSelection(view, 10);
+ else scrollPane(view, 1, 10);
+ return true;
+
+ default:
+ return false;
+ }
+}
+
// ── app ──
async function loadHqtui(): Promise {
@@ -702,6 +1188,8 @@ export type DashboardOptions = {
/** Where the ad network is trying to get to; see lib/dashboard/roi.ts. */
targetImpressions?: number;
targetCtr?: number;
+ /** Initial order of the Traffic list: score, visitors or pageviews. */
+ sort?: string;
};
export async function runDashboard(opts: DashboardOptions): Promise {
@@ -712,20 +1200,13 @@ export async function runDashboard(opts: DashboardOptions): Promise {
quitKeys: ["ctrl+c", "q"],
});
- const state: State = {
- tab: 0,
+ const state: State = initialState({
range: opts.range && RANGES.includes(opts.range as never) ? opts.range : "1d",
who: opts.who ?? "humans",
- snapshot: null,
- loading: false,
- lastRefresh: null,
- error: null,
- paused: false,
- showHelp: false,
- panes: {},
targetImpressions: opts.targetImpressions ?? AD_TARGET_IMPRESSIONS,
targetCtr: opts.targetCtr ?? AD_TARGET_CTR,
- };
+ ...(opts.sort && SORTS.includes(opts.sort as never) ? { sort: opts.sort as Sort } : {}),
+ });
let refreshing = false;
@@ -765,67 +1246,8 @@ export async function runDashboard(opts: DashboardOptions): Promise {
const tick = setInterval(() => app.invalidate(), 1000);
tick.unref?.();
- app.on("key", (event: { name: string; shift?: boolean }) => {
- if (state.showHelp) {
- state.showHelp = false;
- app.invalidate();
- return;
- }
- const digit = Number(event.name);
- if (Number.isInteger(digit) && event.name.length === 1 && digit >= 1 && digit <= TABS.length) {
- state.tab = digit - 1;
- app.invalidate();
- return;
- }
- const view = pane(state, TAB_PANE[state.tab] as string, state.panes[TAB_PANE[state.tab] as string]?.total ?? 0);
- switch (event.name) {
- case "tab":
- case "right":
- case "l":
- state.tab = event.shift ? (state.tab + TABS.length - 1) % TABS.length : (state.tab + 1) % TABS.length;
- break;
- case "left":
- case "h":
- state.tab = (state.tab + TABS.length - 1) % TABS.length;
- break;
- case "r":
- case "f5":
- void refresh();
- break;
- case "w":
- state.range = RANGES[(RANGES.indexOf(state.range as never) + 1) % RANGES.length] as string;
- void refresh();
- break;
- case "b":
- state.who = state.who === "humans" ? "all" : state.who === "all" ? "bots" : "humans";
- void refresh();
- break;
- case "p":
- case "space":
- state.paused = !state.paused;
- break;
- case "?":
- case "f1":
- state.showHelp = true;
- break;
- case "up":
- case "k":
- scrollPane(view, -1);
- break;
- case "down":
- case "j":
- scrollPane(view, 1);
- break;
- case "pageup":
- scrollPane(view, -1, 10);
- break;
- case "pagedown":
- scrollPane(view, 1, 10);
- break;
- default:
- return;
- }
- app.invalidate();
+ app.on("key", (event: KeyLike) => {
+ if (handleKey(state, event, { refresh })) app.invalidate();
});
app.render(({ ui, theme, height }: RenderArgs) => {
@@ -850,27 +1272,17 @@ export async function runDashboard(opts: DashboardOptions): Promise {
});
ui.spacer(1);
- ui.column({ size: height - 4 }, (body) => {
- if (!state.snapshot) {
- body.panel({ title: "Spend & ROI" }, (p) => {
- if (state.error) {
- p.text(`Could not load: ${state.error}`, { fg: theme.danger });
- p.text("Press r to retry, q to quit.", { fg: theme.muted });
- } else {
- p.text("Reading the fleet…", { fg: theme.muted });
- p.text("One tracker call per site, plus ad earnings and CoinPay.", { fg: theme.muted });
- }
- });
- return;
- }
- (SCREENS[state.tab] ?? roiScreen)(body, state, theme);
- });
+ ui.column({ size: height - 4 }, (body) => renderBody(body, state, theme));
ui.spacer(1);
const errorCount = state.snapshot ? Object.keys(state.snapshot.errors).length : 0;
+ const onList = state.tab === 1 && !state.domain;
ui.statusBar({
items: [
{ key: "1-5", label: "Screen" },
+ ...(onList ? [{ key: "↵", label: "Open site" }] : []),
+ ...(state.domain ? [{ key: "esc", label: "Back", active: true }] : []),
+ ...(onList ? [{ key: "s", label: `Sort ${state.sort}` }] : []),
{ key: "r", label: "Refresh" },
{ key: "w", label: `Window ${state.range}` },
{ key: "b", label: state.who },
@@ -886,21 +1298,28 @@ export async function runDashboard(opts: DashboardOptions): Promise {
if (state.showHelp) {
ui.modal({
title: "CrawlProof — Spend & ROI",
- width: 70,
- height: 22,
+ width: 76,
+ height: 28,
message:
"1-5, Tab, ←/→ switch screens.\n" +
`r refreshes now; it also refreshes every ${interval}s.\n` +
"w cycles the window: 1h → 4h → 1d → 1w → 1m.\n" +
"b cycles who counts: humans → all → bots.\n" +
- "p pauses the timer. ↑/↓ j/k, PgUp/PgDn scroll a table.\n\n" +
+ "p pauses the timer. ↑/↓ j/k, PgUp/PgDn move in a table.\n\n" +
+ "On Traffic: ↑/↓ pick a site, Enter or a click opens it,\n" +
+ " Esc / ← / 2 comes back, s cycles the order:\n" +
+ " score → visitors → pageviews.\n\n" +
+ "Risk-to-viral scores a property out of 100:\n" +
+ " score = 100 × viral × (1 − risk/2)\n" +
+ " viral = momentum .40 + discovery .30 + humanity .20 + money .10\n" +
+ " risk = volatility .40 + concentration .30 + bots .20 + unmonetised .10\n" +
+ " A component with no data is dropped, not counted as zero;\n" +
+ " ~ marks too small a sample. The domain screen shows every part.\n\n" +
"Cost is business-scope bank spend as a monthly rate, so it\n" +
" does not move when you change the traffic window.\n" +
"Revenue is CoinPay commission only. Ad spend and ad earnings\n" +
" are the same account on both sides of our own network, so\n" +
- " they are reported under Internal and counted as neither.\n" +
- "Cost each = the monthly burn prorated onto the window,\n" +
- " divided by the visitors who arrived in it.\n\n" +
+ " they are reported under Internal and counted as neither.\n\n" +
"Press any key to close.",
buttons: [{ label: "Close", focused: true }],
});
diff --git a/cli/index.ts b/cli/index.ts
index 1f0afc1..a98c31b 100644
--- a/cli/index.ts
+++ b/cli/index.ts
@@ -504,6 +504,7 @@ async function cmdDashboard(args: Args): Promise {
concurrency: Number(args.flags.concurrency) || 8,
coinpay,
only,
+ sort: args.flags.sort as string | undefined,
theme: args.flags.theme as string | undefined,
});
return 0;
@@ -573,13 +574,24 @@ COMMANDS
project name; with one project it can be left out. Needs an API token.
dashboard [--range=1h|4h|1d|1w|1m] [--who=humans|bots|all] [--interval=60]
- [--sites=a.com,b.com] [--concurrency=8] [--no-coinpay] [--json]
+ [--sites=a.com,b.com] [--sort=score|visitors|pageviews]
+ [--concurrency=8] [--no-coinpay] [--json]
A live terminal dashboard of what the fleet costs and what it returns:
traffic across every site you own, ad delivery, and — when a CoinPay
merchant session is on the box — the bank feed behind it. Five screens:
ROI, Traffic, Ads, Money, Spend. Needs an API token and a terminal;
--json prints the same snapshot for a script. Aliases: roi, tui.
+ On Traffic, ↑/↓ pick a property and Enter (or a click) opens it: that
+ domain's traffic and money on their own, with its risk-to-viral score
+ broken into the parts it was built from. Esc / ← / 2 comes back, s
+ cycles the order. The score is
+ 100 × viral × (1 − risk/2), viral = momentum .40 + discovery .30 +
+ humanity .20 + money .10, risk = volatility .40 + concentration .30 +
+ bot dependence .20 + unmonetised .10.
+ A component with no data is dropped, not counted as zero; ~ marks a
+ sample under 25 human visits.
+
help
Print this message.
@@ -607,6 +619,7 @@ EXAMPLES
CRAWLPROOF_TOKEN=crp_... crawlproof slots create nichedb.dev
CRAWLPROOF_TOKEN=crp_... crawlproof dashboard --range=1w
CRAWLPROOF_TOKEN=crp_... crawlproof dashboard --json | jq .roi.derived
+ CRAWLPROOF_TOKEN=crp_... crawlproof dashboard --json | jq '.sites[] | {site, score: .score.score}'
`);
}
diff --git a/lib/ads/earnings-data.ts b/lib/ads/earnings-data.ts
index e4c8f09..bad2b74 100644
--- a/lib/ads/earnings-data.ts
+++ b/lib/ads/earnings-data.ts
@@ -27,6 +27,12 @@ export type EarningsCampaignRow = {
id: string;
name: string;
status: string;
+ /**
+ * Where the campaign points. Carried so a client can attribute spend to one
+ * of its own properties: a campaign is only ever "for" a site by way of the
+ * page it sends people to, and nothing else in this model names a domain.
+ */
+ url: string | null;
impressions: number;
clicks: number;
spentCents: number;
@@ -35,6 +41,8 @@ export type EarningsCampaignRow = {
export type EarningsSlotRow = {
id: string;
name: string;
+ /** The project the slot sits on, so earnings can be read per property. */
+ projectId: string;
status: string;
impressions: number;
clicks: number;
@@ -103,6 +111,7 @@ type CampaignRow = {
id: string;
name: string;
status: string;
+ destination_url: string | null;
total_spent_cents: number | null;
spend_today_cents: number | null;
spend_date: string | null;
@@ -138,7 +147,7 @@ export async function loadEarnings(
] = await Promise.all([
supabase
.from("ad_campaigns")
- .select("id, name, status, total_spent_cents, spend_today_cents, spend_date")
+ .select("id, name, status, destination_url, total_spent_cents, spend_today_cents, spend_date")
.eq("owner_id", userId),
// Not ad_campaign_stats / ad_slot_stats: those views are lifetime and count
// only tier 'paid', so on a network running entirely on free backfill they
@@ -179,6 +188,7 @@ export async function loadEarnings(
id: c.id,
name: c.name,
status: c.status,
+ url: c.destination_url ?? null,
impressions: deliveredImpressions(s),
clicks: deliveredClicks(s),
spentCents: s.spentCents,
@@ -190,6 +200,7 @@ export async function loadEarnings(
return {
id: sl.id,
name: projectsById.get(sl.project_id)?.name ?? "Site",
+ projectId: sl.project_id,
status: sl.status,
impressions: deliveredImpressions(s),
clicks: deliveredClicks(s),
diff --git a/lib/dashboard/collect.ts b/lib/dashboard/collect.ts
index 30bc466..9e0eb7e 100644
--- a/lib/dashboard/collect.ts
+++ b/lib/dashboard/collect.ts
@@ -20,6 +20,8 @@ import {
type RoiModel,
type SiteTraffic,
} from "./roi";
+import type { ScoreModel } from "./score";
+import { buildSiteDetail, type SiteMix, type SitePoint } from "./site";
export type ListItem = { label: string; value: number };
@@ -29,6 +31,16 @@ export type SiteStats = SiteTraffic & {
sources: ListItem[];
referrers: ListItem[];
pages: ListItem[];
+ /** The shape over the window, for the domain screen and the score. */
+ series?: SitePoint[];
+ /** Humans against bots, unfiltered. Absent when the API did not answer it. */
+ mix?: SiteMix;
+ /**
+ * The risk-to-viral score for this property; see lib/dashboard/score.ts.
+ * Attached here so the Traffic list can rank by it without every screen
+ * recomputing it, and so `--json` carries it for a script.
+ */
+ score?: ScoreModel;
};
export type DashboardSnapshot = {
@@ -119,13 +131,19 @@ async function statsForSite(
range: string,
who: string,
): Promise {
- const url = `${baseUrl}/api/tracker/v1/stats?site=${encodeURIComponent(site.id)}&range=${encodeURIComponent(range)}&who=${encodeURIComponent(who)}`;
+ // `detail=1` asks for the series and the unfiltered human / bot mix. Both are
+ // per-domain questions — the fleet screens need neither — and the mix is the
+ // only honest source for "how much of this is a crawler", because a filtered
+ // series has a zero bot column by construction.
+ const url = `${baseUrl}/api/tracker/v1/stats?site=${encodeURIComponent(site.id)}&range=${encodeURIComponent(range)}&who=${encodeURIComponent(who)}&detail=1`;
try {
const body = await fetchJson<{
totals?: { visitors?: number; pageviews?: number };
sources?: ListItem[];
referrers?: ListItem[];
pages?: ListItem[];
+ series?: SitePoint[];
+ mix?: SiteMix;
}>(url, token);
return {
site: site.name,
@@ -136,6 +154,8 @@ async function statsForSite(
sources: body.sources ?? [],
referrers: body.referrers ?? [],
pages: body.pages ?? [],
+ ...(body.series ? { series: body.series } : {}),
+ ...(body.mix ? { mix: body.mix } : {}),
};
} catch (err) {
return {
@@ -234,6 +254,14 @@ export async function collectDashboard(opts: CollectOptions): Promise;
+ /** Per-campaign spend. `url` is the only field that names a domain. */
+ campaigns?: Array<{
+ id?: string;
+ name?: string;
+ status?: string;
+ url?: string | null;
+ impressions?: number;
+ clicks?: number;
+ spentCents?: number;
+ }>;
totals?: {
spentCents?: number;
earnedCents?: number;
@@ -76,6 +100,11 @@ export type AdsInput = {
/** The subset of the CoinPay finance snapshot this module reads. */
export type FinanceInput = {
windowDays?: number;
+ /**
+ * The merchant's businesses. Read by lib/dashboard/site.ts, which can only
+ * attribute commission to a domain when there is exactly one of them.
+ */
+ businesses?: Array<{ id?: string; name?: string }>;
/**
* The headline earnings figures, which are **lifetime and not windowed**.
*
diff --git a/lib/dashboard/score.ts b/lib/dashboard/score.ts
new file mode 100644
index 0000000..82076de
--- /dev/null
+++ b/lib/dashboard/score.ts
@@ -0,0 +1,390 @@
+// The risk-to-viral score: which property is worth another week of attention.
+//
+// WHY a score at all. The Traffic screen ranks sites by visitors, and visitors
+// is the one number on this fleet that lies — most of it is crawler traffic
+// (lib/tracker/humans.ts), and on a site with a machine-readable endpoint a
+// "visitor" is any hit that was not classified as a crawler, which runs orders
+// of magnitude above the pages anyone read. So the busiest row is routinely the
+// least interesting one. This ranks by the thing that is actually being asked:
+// is this property going anywhere, and how fragile is the answer.
+//
+// It is deliberately arithmetic rather than a model. Every component is one
+// division over numbers already on the screen, every weight is written down
+// here and rendered on the domain screen, and a component with no data is
+// dropped and its weight redistributed rather than counted as a zero. A score
+// nobody can take apart is a score nobody should act on.
+//
+// ── THE FORMULA ────────────────────────────────────────────────────────────
+//
+// Two halves, each a weighted mean of components in 0..1.
+//
+// VIRAL — the upside
+// momentum 0.40 human visits in the recent half of the window against
+// the earlier half: g = (recent - prior) / max(prior, 1),
+// scored 0.5 + g/2, so flat is 0.5, doubling is 1.0 and
+// losing everything is 0.
+// discovery 0.30 share of human arrivals from a channel a stranger can
+// come through — search, social, AI referral, ad, another
+// site's link — rather than direct or our own referral.
+// Reach that compounds, as opposed to reach we already had.
+// humanity 0.20 humans / (humans + bots). Needs the unfiltered mix; when
+// the window was asked for one side only it is unknown and
+// this component is dropped rather than assumed.
+// money 0.10 revenue per 1,000 human visits against TARGET_RPM_USD.
+// Small on purpose: nothing on this fleet is monetised yet
+// and weighting it heavily would score every property 0.
+//
+// RISK — the fragility, which is what "risk-to-viral" names
+// volatility 0.40 coefficient of variation of the human series over
+// the window, against CV_CEILING.
+// concentration 0.30 the largest single arrival channel's share, rescaled
+// from CONCENTRATION_FLOOR..1 onto 0..1: an even split
+// across channels is not a risk, one channel being
+// everything is the whole risk.
+// botDependence 0.20 1 - humanity.
+// unmonetised 0.10 1 - money.
+//
+// score = 100 × viral × (1 − risk / 2)
+//
+// So risk can halve a property's score and never more, and a property with no
+// upside scores 0 however safe it is — which is the right shape for a question
+// about where to spend the next week.
+//
+// Every input is coerced, every denominator is guarded, and nothing here can
+// return NaN or Infinity: see `clamp01` and the `safeDiv` calls.
+
+/** Revenue per 1,000 human visits that counts as fully monetised. */
+export const TARGET_RPM_USD = 2;
+
+/** Coefficient of variation at which the volatility component saturates. */
+export const CV_CEILING = 1.5;
+
+/** Below this share, one dominant channel is not yet counted as a risk. */
+export const CONCENTRATION_FLOOR = 0.5;
+
+/** Human visits below which a score is labelled provisional rather than read. */
+export const MIN_SAMPLE_HUMANS = 25;
+
+export const VIRAL_WEIGHTS = {
+ momentum: 0.4,
+ discovery: 0.3,
+ humanity: 0.2,
+ money: 0.1,
+} as const;
+
+export const RISK_WEIGHTS = {
+ volatility: 0.4,
+ concentration: 0.3,
+ botDependence: 0.2,
+ unmonetised: 0.1,
+} as const;
+
+/**
+ * Arrival buckets a stranger can reach us through.
+ *
+ * `bucketLabel` in lib/tracker/categorize.ts renders the bucket as
+ * "Search · google", "AI · chatgpt" and so on, and the dashboard carries those
+ * labels rather than the raw buckets, so both spellings are matched here.
+ * "Direct" and a self-referral are reach we already had; they are not counted.
+ */
+const DISCOVERY_PREFIXES = ["search", "social", "ai", "ad", "referral", "ai_referral"];
+
+const num = (v: unknown): number => {
+ const x = Number(v);
+ return Number.isFinite(x) ? x : 0;
+};
+
+/**
+ * 0..1, with NaN ordered to 0 rather than propagated.
+ *
+ * Infinity saturates rather than falling to zero: an unbounded ratio means the
+ * component is off the top of its scale, and reading that as "nothing here"
+ * would score the most volatile property as the safest one.
+ */
+export function clamp01(value: number): number {
+ if (Number.isNaN(value)) return 0;
+ return value < 0 ? 0 : value > 1 ? 1 : value;
+}
+
+/** A ratio, or null when the denominator cannot carry one. */
+function safeDiv(top: number, bottom: number): number | null {
+ if (!Number.isFinite(top) || !Number.isFinite(bottom) || bottom <= 0) return null;
+ const out = top / bottom;
+ return Number.isFinite(out) ? out : null;
+}
+
+export type ScoreItem = { label: string; value: number };
+
+export type ScoreInput = {
+ /** Human visits per bucket across the window, oldest first. */
+ humans: number[];
+ /** Bot hits per bucket, from the unfiltered mix. Empty when it is unknown. */
+ bots?: number[];
+ /** Arrival channels for the window, as the dashboard already carries them. */
+ sources?: ScoreItem[];
+ /** Money attributable to this property over the window, in dollars. */
+ revenueUsd?: number;
+ /**
+ * Human visits over the window, when a truer total than the series sum is
+ * known. The series is filtered to whichever side was asked for, so under
+ * `who=bots` its human column is zero by construction; the unfiltered mix
+ * knows the real figure and this is where it comes in.
+ */
+ humansTotal?: number;
+ /** Bot hits over the window, from the same unfiltered read. */
+ botsTotal?: number;
+ /**
+ * True when the mix of humans against bots is known for this window. It is
+ * not when the caller asked for one side only and nothing counted the other.
+ */
+ mixKnown?: boolean;
+};
+
+export type Component = {
+ key: string;
+ label: string;
+ /** 0..1, or null when there was nothing to compute it from. */
+ value: number | null;
+ weight: number;
+ /** The raw figure behind the component, for the detail screen. */
+ detail: string;
+};
+
+export type ScoreModel = {
+ /** 0..100, or null when neither half could be computed. */
+ score: number | null;
+ /** 0..1. */
+ viral: number;
+ /** 0..1. */
+ risk: number;
+ viralComponents: Component[];
+ riskComponents: Component[];
+ /** Human visits the score was computed over. */
+ humans: number;
+ /** True when the sample is too small to lean on. */
+ provisional: boolean;
+ /** Share of the weight that had data behind it, across both halves. */
+ coverage: number;
+ /** Why a component is missing, or why the score should not be read straight. */
+ notes: string[];
+};
+
+const sum = (values: number[]): number => values.reduce((total, v) => total + num(v), 0);
+
+/**
+ * Growth of the recent half of the window against the earlier half.
+ *
+ * Halves rather than a fitted trend because the window is as short as thirteen
+ * five-minute buckets and as long as thirty days, and a fit over thirteen noisy
+ * buckets says more about the noise than the site. Null when there are fewer
+ * than four buckets, which is the point below which "recent" and "earlier" are
+ * the same two numbers.
+ */
+export function growthRate(series: number[]): number | null {
+ const points = (series ?? []).map(num);
+ if (points.length < 4) return null;
+ const mid = Math.floor(points.length / 2);
+ const prior = sum(points.slice(0, mid));
+ const recent = sum(points.slice(mid));
+ if (prior <= 0 && recent <= 0) return null;
+ // max(prior, 1) rather than a guard: from nothing to something is growth, and
+ // dividing by zero to say so is not.
+ return (recent - prior) / Math.max(prior, 1);
+}
+
+/**
+ * Coefficient of variation: standard deviation over the mean.
+ *
+ * Scale-free on purpose, so a site doing 20 visits a day and one doing 20,000
+ * are asked the same question — how steady is it — rather than the larger one
+ * always reading as the more volatile.
+ */
+export function coefficientOfVariation(series: number[]): number | null {
+ const points = (series ?? []).map(num);
+ if (points.length < 3) return null;
+ const mean = sum(points) / points.length;
+ if (mean <= 0) return null;
+ const variance = points.reduce((total, v) => total + (v - mean) ** 2, 0) / points.length;
+ const cv = safeDiv(Math.sqrt(variance), mean);
+ return cv === null ? null : cv;
+}
+
+/** Share of arrivals from a channel a stranger can come through. */
+export function discoveryShare(sources: ScoreItem[] | undefined): number | null {
+ const items = (sources ?? []).filter((s) => s && typeof s.label === "string");
+ const total = sum(items.map((s) => num(s.value)));
+ if (total <= 0) return null;
+ const discovered = items
+ .filter((s) => {
+ const head = s.label.split("·")[0]?.trim().toLowerCase() ?? "";
+ return DISCOVERY_PREFIXES.some((p) => head === p || head.startsWith(`${p}:`) || head.startsWith(`${p} `));
+ })
+ .reduce((t, s) => t + num(s.value), 0);
+ return clamp01(discovered / total);
+}
+
+/** The largest single channel's share of arrivals. */
+export function topSourceShare(sources: ScoreItem[] | undefined): number | null {
+ const values = (sources ?? []).map((s) => num(s?.value)).filter((v) => v > 0);
+ const total = sum(values);
+ if (total <= 0) return null;
+ return clamp01(Math.max(...values) / total);
+}
+
+/** A weighted mean over the components that have a value, weights renormalised. */
+function weightedMean(components: Component[]): { value: number; weight: number } {
+ let weighted = 0;
+ let weight = 0;
+ for (const c of components) {
+ if (c.value === null) continue;
+ weighted += clamp01(c.value) * c.weight;
+ weight += c.weight;
+ }
+ return { value: weight > 0 ? weighted / weight : 0, weight };
+}
+
+const pct = (v: number | null): string => (v === null ? "no data" : `${(v * 100).toFixed(0)}%`);
+
+/**
+ * Score one property. Pure, total, and safe against every empty shape: an input
+ * of `{ humans: [] }` returns a null score with notes, never a NaN.
+ */
+export function scoreSite(input: ScoreInput): ScoreModel {
+ const humansSeries = (input.humans ?? []).map(num);
+ const botsSeries = (input.bots ?? []).map(num);
+ const humans = input.humansTotal === undefined ? sum(humansSeries) : num(input.humansTotal);
+ const bots = input.botsTotal === undefined ? sum(botsSeries) : num(input.botsTotal);
+ const notes: string[] = [];
+
+ const growth = growthRate(humansSeries);
+ if (growth === null && humansSeries.length < 4) {
+ notes.push("Too few buckets in this window to measure growth; widen it with w.");
+ }
+
+ const mixKnown = input.mixKnown !== false && (humans > 0 || bots > 0);
+ const humanity = mixKnown ? safeDiv(humans, humans + bots) : null;
+ if (!mixKnown) {
+ notes.push("Humans against bots is unknown for this window, so that component is not counted.");
+ }
+
+ const discovery = discoveryShare(input.sources);
+ const concentrationRaw = topSourceShare(input.sources);
+ const cv = coefficientOfVariation(humansSeries);
+
+ const revenueUsd = num(input.revenueUsd);
+ const rpm = safeDiv(revenueUsd * 1000, humans);
+ const money = rpm === null ? null : clamp01(rpm / TARGET_RPM_USD);
+
+ const viralComponents: Component[] = [
+ {
+ key: "momentum",
+ label: "Momentum",
+ value: growth === null ? null : clamp01(0.5 + growth / 2),
+ weight: VIRAL_WEIGHTS.momentum,
+ detail:
+ growth === null
+ ? "no data"
+ : `${growth >= 0 ? "+" : ""}${(growth * 100).toFixed(0)}% human visits, recent half vs earlier`,
+ },
+ {
+ key: "discovery",
+ label: "Discovery",
+ value: discovery,
+ weight: VIRAL_WEIGHTS.discovery,
+ detail: discovery === null ? "no arrivals" : `${pct(discovery)} arrived via search, social, AI, ad or a link`,
+ },
+ {
+ key: "humanity",
+ label: "Humanity",
+ value: humanity,
+ weight: VIRAL_WEIGHTS.humanity,
+ detail:
+ humanity === null
+ ? "mix unknown"
+ : `${humans.toLocaleString("en-US")} human of ${(humans + bots).toLocaleString("en-US")} hits`,
+ },
+ {
+ key: "money",
+ label: "Money",
+ value: money,
+ weight: VIRAL_WEIGHTS.money,
+ detail:
+ rpm === null
+ ? "no human visits to divide by"
+ : `$${rpm.toFixed(2)} per 1k human visits (target $${TARGET_RPM_USD.toFixed(2)})`,
+ },
+ ];
+
+ const concentration =
+ concentrationRaw === null
+ ? null
+ : clamp01((concentrationRaw - CONCENTRATION_FLOOR) / (1 - CONCENTRATION_FLOOR));
+
+ const riskComponents: Component[] = [
+ {
+ key: "volatility",
+ label: "Volatility",
+ value: cv === null ? null : clamp01(cv / CV_CEILING),
+ weight: RISK_WEIGHTS.volatility,
+ detail: cv === null ? "no data" : `CV ${cv.toFixed(2)} across ${humansSeries.length} buckets`,
+ },
+ {
+ key: "concentration",
+ label: "Channel concentration",
+ value: concentration,
+ weight: RISK_WEIGHTS.concentration,
+ detail:
+ concentrationRaw === null
+ ? "no arrivals"
+ : `biggest channel is ${pct(concentrationRaw)} of arrivals`,
+ },
+ {
+ key: "botDependence",
+ label: "Bot dependence",
+ value: humanity === null ? null : clamp01(1 - humanity),
+ weight: RISK_WEIGHTS.botDependence,
+ detail: humanity === null ? "mix unknown" : `${pct(humanity === null ? null : 1 - humanity)} of hits are crawlers`,
+ },
+ {
+ key: "unmonetised",
+ label: "Unmonetised",
+ value: money === null ? null : clamp01(1 - money),
+ weight: RISK_WEIGHTS.unmonetised,
+ detail: rpm === null ? "no revenue basis" : `$${revenueUsd.toFixed(2)} attributable in this window`,
+ },
+ ];
+
+ const viral = weightedMean(viralComponents);
+ const risk = weightedMean(riskComponents);
+ const totalWeight = viral.weight + risk.weight;
+ const coverage = clamp01(totalWeight / 2);
+
+ const provisional = humans < MIN_SAMPLE_HUMANS;
+ if (provisional) {
+ notes.push(
+ `Only ${humans.toLocaleString("en-US")} human visits in this window; under ${MIN_SAMPLE_HUMANS} the score is a guess with error bars.`,
+ );
+ }
+
+ // Nothing at all is a real answer and should read as one, rather than as the
+ // zero a property that was measured and found dead would get.
+ const score = viral.weight <= 0 && risk.weight <= 0 ? null : 100 * viral.value * (1 - risk.value / 2);
+
+ return {
+ score: score === null ? null : Math.round(score * 10) / 10,
+ viral: viral.value,
+ risk: risk.value,
+ viralComponents,
+ riskComponents,
+ humans,
+ provisional,
+ coverage,
+ notes,
+ };
+}
+
+/** The one-line explanation the CLI help, the README and the screen all use. */
+export const SCORE_FORMULA =
+ "score = 100 × viral × (1 − risk/2), where viral is momentum .40 + discovery .30 + humanity .20 + money .10 " +
+ "and risk is volatility .40 + channel concentration .30 + bot dependence .20 + unmonetised .10. " +
+ "Components with no data are dropped and their weight redistributed.";
diff --git a/lib/dashboard/site.ts b/lib/dashboard/site.ts
new file mode 100644
index 0000000..cea4acf
--- /dev/null
+++ b/lib/dashboard/site.ts
@@ -0,0 +1,305 @@
+// One property, on its own: what arrived, what it cost, what it earned.
+//
+// The fleet screens answer "how are we doing". This answers "is THIS domain
+// worth another week", which is a different question and needs the money split
+// per property rather than summed across it.
+//
+// Pure, and built entirely from the snapshot the dashboard already collected —
+// opening a domain fires no new request, so the detail screen can never
+// disagree with the list it was opened from.
+//
+// Three money lines, each attributable by a different join, and each says which:
+//
+// cost the fleet's burn, prorated onto this property by its share of
+// traffic. Two of them, because the two denominators disagree by
+// orders of magnitude on this fleet (see the visitors caveat in
+// lib/dashboard/roi.ts) and picking one quietly would be a lie.
+// ad money earnings by slot → project id, spend by campaign →
+// destination host. Both exact. Both internal: this network has
+// one account on both sides, so neither is revenue.
+// revenue CoinPay commission, which is the only money from outside the
+// fleet — and is attributable to a domain only when the merchant
+// account has exactly one business and it is this one. Otherwise
+// it is null and the screen says why rather than dividing the
+// fleet's revenue by a number of sites.
+
+import type { AdsInput, FinanceInput, RoiModel } from "./roi";
+import { scoreSite, type ScoreItem, type ScoreModel } from "./score";
+
+export type SitePoint = { date: string; pageviews: number; humans: number; bots: number; ai: number };
+
+export type SiteMix = { humans: number; bots: number; ai: number; events: number };
+
+/** What the collector carries for each property. */
+export type SiteLike = {
+ site: string;
+ id?: string;
+ url?: string;
+ visitors: number;
+ pageviews: number;
+ sources: ScoreItem[];
+ referrers: ScoreItem[];
+ pages: ScoreItem[];
+ series?: SitePoint[];
+ mix?: SiteMix;
+ error?: string;
+};
+
+export type SiteMoney = {
+ /** Burn prorated by this property's share of fleet pageviews. */
+ costByViewsUsd: number | null;
+ /** The same, by its share of fleet visits. Flattering; see the module note. */
+ costByVisitsUsd: number | null;
+ adEarnedUsd: number;
+ adSpentUsd: number;
+ adImpressions: number;
+ adClicks: number;
+ /** Money from outside the fleet, or null when it cannot be attributed here. */
+ revenueUsd: number | null;
+ /** How `revenueUsd` was arrived at, for the screen to print. */
+ revenueBasis: string;
+ /** Revenue per 1,000 human visits, when both halves exist. */
+ rpmUsd: number | null;
+ /** Revenue less the cost-by-views share. Null when either half is null. */
+ netUsd: number | null;
+};
+
+export type SiteDetail = {
+ site: string;
+ url: string | null;
+ error: string | null;
+ window: { range: string; who: string; days: number; financeDays: number };
+ traffic: {
+ visitors: number;
+ pageviews: number;
+ humans: number;
+ bots: number;
+ aiReferrals: number;
+ /** humans / (humans + bots), or null when the mix is unknown. */
+ humanShare: number | null;
+ mixKnown: boolean;
+ visitShare: number;
+ viewShare: number;
+ series: SitePoint[];
+ sources: ScoreItem[];
+ referrers: ScoreItem[];
+ pages: ScoreItem[];
+ };
+ money: SiteMoney;
+ score: ScoreModel;
+ /** What could not be attributed to this property, named rather than guessed. */
+ gaps: string[];
+};
+
+const num = (v: unknown): number => {
+ const x = Number(v);
+ return Number.isFinite(x) ? x : 0;
+};
+
+const cents = (v: unknown): number => num(v) / 100;
+
+const share = (part: number, whole: number): number => (whole > 0 ? num(part) / whole : 0);
+
+/**
+ * A hostname from a URL or a bare host, lowercased and de-`www`'d.
+ *
+ * Deliberately not lib/ads/slots' `hostOf`: this module is bundled into the
+ * published CLI, and that one reaches through net-guard into server code.
+ */
+export function hostFrom(input: string | null | undefined): string | null {
+ const raw = String(input ?? "").trim();
+ if (!raw) return null;
+ try {
+ const url = new URL(/^[a-z]+:\/\//i.test(raw) ? raw : `https://${raw}`);
+ return url.hostname.toLowerCase().replace(/^www\./, "") || null;
+ } catch {
+ return null;
+ }
+}
+
+/** True when a site row and a URL name the same property. */
+export function sameProperty(site: SiteLike, url: string | null | undefined): boolean {
+ const target = hostFrom(url);
+ if (!target) return false;
+ const host = hostFrom(site.url) ?? hostFrom(site.site);
+ if (host && host === target) return true;
+ // A project named for its host but with no URL on file still matches.
+ return site.site.toLowerCase() === target;
+}
+
+/**
+ * CoinPay commission attributable to one property.
+ *
+ * Only when the merchant account has exactly one business and it is this one.
+ * With several, the snapshot carries a fleet total and no per-business split —
+ * `getFinanceAnalytics` takes a `businessId` but the dashboard makes one call,
+ * not one per business — so anything else would be the fleet's revenue divided
+ * by a guess.
+ */
+export function coinpayRevenueForSite(
+ finance: FinanceInput | null,
+ roi: RoiModel,
+ site: SiteLike,
+): { usd: number | null; basis: string } {
+ const businesses = (finance as { businesses?: Array<{ id?: string; name?: string }> } | null)?.businesses ?? [];
+ if (!finance) return { usd: null, basis: "no CoinPay session" };
+ if (businesses.length !== 1) {
+ return {
+ usd: null,
+ basis: businesses.length
+ ? `${businesses.length} CoinPay businesses, no per-business split in the snapshot`
+ : "CoinPay reported no businesses",
+ };
+ }
+ const only = businesses[0] as { id?: string; name?: string };
+ const name = String(only?.name ?? "");
+ if (!(sameProperty(site, name) || site.site.toLowerCase() === name.toLowerCase())) {
+ return { usd: null, basis: `all commission belongs to ${name || "another business"}` };
+ }
+ return { usd: num(roi.revenue.windowUsd), basis: `all CoinPay commission (${name})` };
+}
+
+/** Ad money and delivery for one property, joined exactly rather than shared out. */
+export function adMoneyForSite(
+ ads: AdsInput | null,
+ site: SiteLike,
+): { earnedUsd: number; spentUsd: number; impressions: number; clicks: number } {
+ const model = (ads ?? {}) as AdsInput & {
+ slots?: Array<{ projectId?: string; name?: string; earnedCents?: number; impressions?: number; clicks?: number }>;
+ campaigns?: Array<{ url?: string | null; spentCents?: number }>;
+ };
+ const out = { earnedUsd: 0, spentUsd: 0, impressions: 0, clicks: 0 };
+
+ for (const slot of model.slots ?? []) {
+ const mine = site.id ? slot.projectId === site.id : slot.name === site.site;
+ if (!mine) continue;
+ out.earnedUsd += cents(slot.earnedCents);
+ out.impressions += num(slot.impressions);
+ out.clicks += num(slot.clicks);
+ }
+ for (const campaign of model.campaigns ?? []) {
+ if (!sameProperty(site, campaign.url)) continue;
+ out.spentUsd += cents(campaign.spentCents);
+ }
+ return out;
+}
+
+export type BuildSiteDetailInput = {
+ site: SiteLike;
+ roi: RoiModel;
+ ads: AdsInput | null;
+ finance: FinanceInput | null;
+ window: { range: string; who: string; financeDays: number };
+};
+
+export function buildSiteDetail(input: BuildSiteDetailInput): SiteDetail {
+ const { site, roi } = input;
+ const gaps: string[] = [];
+
+ const series = site.series ?? [];
+ const mix = site.mix;
+ // Under `who=all` a filtered read was never made, so the mix IS the series;
+ // under humans or bots it is the second, unfiltered read the API adds. When
+ // neither is there the share is unknown, and unknown is not 100%.
+ const mixKnown = Boolean(mix && mix.humans + mix.bots > 0);
+ const humans = mixKnown ? num(mix?.humans) : series.reduce((t, p) => t + num(p.humans), 0);
+ const bots = mixKnown ? num(mix?.bots) : 0;
+ const aiReferrals = mixKnown ? num(mix?.ai) : series.reduce((t, p) => t + num(p.ai), 0);
+ const humanShare = mixKnown && humans + bots > 0 ? humans / (humans + bots) : null;
+
+ if (!series.length && !site.error) {
+ gaps.push("No series for this window, so momentum and volatility are unscored.");
+ }
+ if (!mixKnown && !site.error) {
+ gaps.push("The humans-against-bots mix is missing; press b for All to see both sides.");
+ }
+
+ const visitShare = share(site.visitors, roi.attention.visitors);
+ const viewShare = share(site.pageviews, roi.attention.pageviews);
+ const costWindow = num(roi.cost.windowUsd);
+ const costByViews = roi.attention.pageviews > 0 ? costWindow * viewShare : null;
+ const costByVisits = roi.attention.visitors > 0 ? costWindow * visitShare : null;
+
+ const ad = adMoneyForSite(input.ads, site);
+ const revenue = coinpayRevenueForSite(input.finance, roi, site);
+ if (revenue.usd === null) {
+ gaps.push(`Revenue is not attributable to one domain here: ${revenue.basis}.`);
+ }
+
+ // The earn rail is a network-wide pool — crawler pass revenue funds it with
+ // the pass payment's own ref and no project column — so there is no per-domain
+ // figure to show. Named rather than omitted, because a missing money line on
+ // a money screen reads as a zero.
+ gaps.push("Earn-rail rewards are pooled network-wide; there is no per-domain share to report.");
+
+ // Human visits are the denominator for a per-reader figure, and the score
+ // half that pays attention to money uses the same one. Ad earnings are
+ // internal and deliberately excluded: see the two rules in roi.ts.
+ const rpmUsd = humans > 0 && revenue.usd !== null ? (revenue.usd * 1000) / humans : null;
+
+ // A site whose stats call failed is not scored at all. Its sources list may
+ // still hold something from a previous shape, and scoring off half an answer
+ // would rank a site we could not reach against sites we could.
+ //
+ // Otherwise: momentum and volatility read the shape of the series, which is
+ // filtered to whichever side was asked for; humanity, money and the sample
+ // floor read the unfiltered totals, the only place both sides are counted.
+ const score = site.error
+ ? scoreSite({ humans: [], sources: [], mixKnown: false })
+ : scoreSite({
+ humans: series.map((p) => num(p.humans)),
+ bots: series.map((p) => num(p.bots)),
+ sources: site.sources ?? [],
+ revenueUsd: revenue.usd ?? 0,
+ mixKnown,
+ ...(mixKnown ? { humansTotal: humans, botsTotal: bots } : {}),
+ });
+ if (site.error) {
+ gaps.push("This site did not answer, so it is unscored rather than scored zero.");
+ }
+
+ if (input.window.who === "bots") {
+ gaps.push("A bots-only window has no human series, so momentum and volatility are unscored.");
+ }
+
+ return {
+ site: site.site,
+ url: site.url ?? null,
+ error: site.error ?? null,
+ window: {
+ range: input.window.range,
+ who: input.window.who,
+ days: roi.window.days,
+ financeDays: input.window.financeDays,
+ },
+ traffic: {
+ visitors: num(site.visitors),
+ pageviews: num(site.pageviews),
+ humans,
+ bots,
+ aiReferrals,
+ humanShare,
+ mixKnown,
+ visitShare,
+ viewShare,
+ series,
+ sources: site.sources ?? [],
+ referrers: site.referrers ?? [],
+ pages: site.pages ?? [],
+ },
+ money: {
+ costByViewsUsd: costByViews,
+ costByVisitsUsd: costByVisits,
+ adEarnedUsd: ad.earnedUsd,
+ adSpentUsd: ad.spentUsd,
+ adImpressions: ad.impressions,
+ adClicks: ad.clicks,
+ revenueUsd: revenue.usd,
+ revenueBasis: revenue.basis,
+ rpmUsd,
+ netUsd: revenue.usd === null || costByViews === null ? null : revenue.usd - costByViews,
+ },
+ score,
+ gaps,
+ };
+}
diff --git a/lib/tracker/apiStats.ts b/lib/tracker/apiStats.ts
index f1f67d1..b475ed4 100644
--- a/lib/tracker/apiStats.ts
+++ b/lib/tracker/apiStats.ts
@@ -10,7 +10,14 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { hostOf } from "@/lib/ads/slots";
-import { fetchPanels, type ListItem, type PanelKey, type PanelPayload } from "@/lib/tracker/panels";
+import {
+ fetchPanel,
+ fetchPanels,
+ resolveDays,
+ type ListItem,
+ type PanelKey,
+ type PanelPayload,
+} from "@/lib/tracker/panels";
import type { TrackerRange } from "@/lib/tracker/ranges";
import type { TrackerKind } from "@/lib/tracker/humans";
@@ -88,6 +95,15 @@ export async function resolveProject(sb: Sb, userId: string, site: string | null
return { ok: false, status: 404, error: `No project for "${site}". Yours: ${projects.map((p) => p.name).join(", ")}` };
}
+/** One bucket of the series, trimmed to what a client can plot or score. */
+export type StatsPoint = {
+ date: string;
+ pageviews: number;
+ humans: number;
+ bots: number;
+ ai: number;
+};
+
export type StatsAnswer = {
project: { id: string; name: string; url: string };
range: string;
@@ -96,10 +112,61 @@ export type StatsAnswer = {
sources: ListItem[];
referrers: ListItem[];
pages: ListItem[];
+ /**
+ * The shape over time, present only with `detail`. It is what the totals were
+ * summed from, so asking for it costs nothing extra.
+ */
+ series?: StatsPoint[];
+ /**
+ * Humans against bots over the same window, unfiltered.
+ *
+ * A filtered answer cannot carry this: asking for `who=humans` filters the
+ * RPC to `p_kind = 'human'`, so its bot column is zero by construction rather
+ * than by observation, and a share computed from it would read 100% human on
+ * a site that is 99% crawler. So this is a second, unfiltered read — skipped
+ * when the caller already asked for everything, where the main series IS it.
+ */
+ mix?: { humans: number; bots: number; ai: number; events: number };
};
const asList = (payload: PanelPayload | undefined): ListItem[] => (Array.isArray(payload) ? payload : []);
+const count = (v: unknown): number => {
+ const x = Number(v);
+ return Number.isFinite(x) ? x : 0;
+};
+
+/** The series payload as plottable points. Empty for a list payload or nothing. */
+export function seriesPoints(payload: PanelPayload | undefined): StatsPoint[] {
+ if (!payload || Array.isArray(payload)) return [];
+ const points = (payload as { points?: Record[] }).points ?? [];
+ return points.map((p) => ({
+ date: String(p.date ?? ""),
+ pageviews: count(p.pageviews),
+ humans: count(p.humans),
+ bots: count(p.bots),
+ ai: count(p.ai),
+ }));
+}
+
+/** Sum a series payload into the human / bot split. */
+export function mixFromSeries(payload: PanelPayload | undefined): {
+ humans: number;
+ bots: number;
+ ai: number;
+ events: number;
+} {
+ const mix = { humans: 0, bots: 0, ai: 0, events: 0 };
+ if (!payload || Array.isArray(payload)) return mix;
+ for (const p of (payload as { points?: Record[] }).points ?? []) {
+ mix.humans += count(p.humans);
+ mix.bots += count(p.bots);
+ mix.ai += count(p.ai);
+ mix.events += count(p.events);
+ }
+ return mix;
+}
+
/** Sum a series payload's points into the two numbers a summary line needs. */
export function totalsFromSeries(payload: PanelPayload | undefined): { visitors: number; pageviews: number } {
if (!payload || Array.isArray(payload)) return { visitors: 0, pageviews: 0 };
@@ -119,9 +186,19 @@ export async function projectStats(
range: TrackerRange,
kind: TrackerKind | null,
who: string,
+ /** Add the series and the unfiltered human / bot mix. One extra RPC at most. */
+ detail = false,
): Promise {
- const panels = await fetchPanels(sb, project.id, STATS_PANELS, range, kind);
- return {
+ const [panels, mixSeries] = await Promise.all([
+ fetchPanels(sb, project.id, STATS_PANELS, range, kind),
+ // Only when the answer is filtered: at kind null the main series already is
+ // the unfiltered one, and a second identical query would be a second query.
+ detail && kind !== null
+ ? fetchPanel(sb, project.id, "series", range, await resolveDays(sb, project.id, range), null)
+ : Promise.resolve(undefined),
+ ]);
+
+ const answer: StatsAnswer = {
project: { id: project.id, name: project.name, url: project.url },
range: range.key,
who,
@@ -130,4 +207,11 @@ export async function projectStats(
referrers: asList(panels.referrers),
pages: asList(panels.pages),
};
+ if (!detail) return answer;
+
+ return {
+ ...answer,
+ series: seriesPoints(panels.series),
+ mix: mixFromSeries(kind === null ? panels.series : mixSeries),
+ };
}
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 4a5026d..6e4c16b 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -14,7 +14,7 @@ and CoinPay for what the bank actually did.
| Screen | Answers |
| --- | --- |
| ROI | Monthly burn against revenue, cost per reader, break-even |
-| Traffic | Every site on the account, ranked, with its share of the cost |
+| Traffic | Every site on the account, ranked, with its score and its share of the cost — open one for its own numbers |
| Ads | Delivery as advertiser and as publisher, and ad-driven arrivals |
| Money | Earnings, bank position, invoices, income vs spending by month |
| Spend | Who we pay, largest first, and burn by category |
@@ -22,6 +22,47 @@ and CoinPay for what the bank actually did.
`1`–`5` or Tab switches screens, `w` cycles the window, `b` cycles humans /
all / bots, `r` refreshes, `?` explains the arithmetic, `q` quits.
+## One property at a time
+
+On **Traffic**, `↑`/`↓` pick a site and `Enter` — or a click on the row — opens
+it. That screen is only that domain: its pageviews, visits, humans against
+bots, AI referrals and where they arrived from; the burn prorated onto it by
+both denominators; its ad earnings and spend, joined by project and by where
+the campaign points; and the commission, when there is exactly one merchant
+business to attribute it to. `Esc`, `←` or `2` comes back to the list.
+
+## The risk-to-viral score
+
+Every property gets a score out of 100, shown as a column on the list and taken
+apart on the domain screen. It is arithmetic over numbers already on the screen,
+not a model:
+
+```
+score = 100 × viral × (1 − risk/2)
+
+viral = momentum .40 + discovery .30 + humanity .20 + money .10
+risk = volatility .40 + concentration .30 + bot dependence .20 + unmonetised .10
+```
+
+| Component | What it is |
+| --- | --- |
+| momentum | Human visits in the recent half of the window against the earlier half. Flat scores 0.5, doubling scores 1. |
+| discovery | Share of arrivals through search, social, an AI assistant, an ad or another site's link, rather than direct. |
+| humanity | Humans over humans plus bots, from an unfiltered read — never from a filtered one, whose bot column is zero by construction. |
+| money | Revenue per 1,000 human visits against a $2 target. |
+| volatility | Coefficient of variation of the human series. Scale-free, so a small site is not penalised for being small. |
+| concentration | The largest single arrival channel's share. An even spread is not a risk; one channel being everything is. |
+| bot dependence | 1 − humanity. |
+| unmonetised | 1 − money. |
+
+A component with no data behind it is **dropped and its weight redistributed**,
+never counted as a zero, and the domain screen prints the raw figure under each
+one. A trailing `~` marks fewer than 25 human visits in the window — too small a
+sample to lean on. A site whose stats call failed is not scored at all.
+
+`s` cycles the order: score, visitors, pageviews. `--sort=score` starts there.
+`--json` carries `.sites[].score` with every component, for a script.
+
## Two rules the numbers keep
**Self-deal is not revenue.** Where an account advertises on its own slots, ad
@@ -48,7 +89,7 @@ per-pageview figure instead.
```
crawlproof dashboard [--range=1h|4h|1d|1w|1m] [--who=humans|bots|all]
[--interval=60] [--sites=a.com,b.com] [--concurrency=8]
- [--no-coinpay] [--json]
+ [--sort=score|visitors|pageviews] [--no-coinpay] [--json]
crawlproof stats [site] [--range=1d] [--who=humans] [--json]
```
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 35e55dd..f434a9f 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -98,12 +98,34 @@ USAGE
COMMANDS
dashboard [--range=1h|4h|1d|1w|1m] [--who=humans|bots|all] [--interval=60]
- [--sites=a.com,b.com] [--concurrency=8] [--no-coinpay] [--json]
+ [--sites=a.com,b.com] [--sort=score|visitors|pageviews]
+ [--concurrency=8] [--no-coinpay] [--json]
A live dashboard of traffic across every site on the account, ad
delivery, and — when a CoinPay merchant session is on the box — the bank
feed behind it. Five screens: ROI, Traffic, Ads, Money, Spend.
Aliases: roi, tui. --json prints the same snapshot for a script.
+ On Traffic, ↑/↓ pick a property and Enter — or a click — opens it: that
+ one domain's traffic and money, and every part of its score. Esc, ← or 2
+ comes back. s cycles the order: score, visitors, pageviews.
+
+ Risk-to-viral score
+ Each property is scored out of 100 from data already on the screen:
+
+ score = 100 × viral × (1 − risk/2)
+ viral = momentum .40 + discovery .30 + humanity .20 + money .10
+ risk = volatility .40 + concentration .30 + bots .20 + unmonetised .10
+
+ momentum is human visits in the recent half of the window against the
+ earlier half; discovery is the share arriving through search, social, AI,
+ an ad or a link rather than direct; humanity is humans over humans plus
+ bots; money is revenue per 1,000 human visits against $2. volatility is
+ the series' coefficient of variation, concentration is the largest single
+ channel's share. A component with no data is dropped and its weight
+ redistributed — never counted as a zero — and a trailing ~ marks a sample
+ under 25 human visits. The domain screen shows every component and the
+ number behind it.
+
stats [site] [--range=1h|4h|1d|1w|1m] [--who=humans|bots|all] [--json]
Who arrived and from where: sources, referrers and top pages. Defaults
to the last day and humans only, because a launch is invisible inside a
@@ -215,6 +237,7 @@ async function cmdDashboard(args: Args): Promise {
concurrency: Number(args.flags.concurrency) || 8,
coinpay,
only,
+ sort: args.flags.sort as string | undefined,
theme: args.flags.theme as string | undefined,
});
return 0;
diff --git a/tests/dashboard-score.test.ts b/tests/dashboard-score.test.ts
new file mode 100644
index 0000000..9c5e6bb
--- /dev/null
+++ b/tests/dashboard-score.test.ts
@@ -0,0 +1,254 @@
+/**
+ * The risk-to-viral score.
+ *
+ * Two things are being pinned here. The first is that the arithmetic means what
+ * the comment in lib/dashboard/score.ts says it means — growth raises a score,
+ * crawler traffic lowers it, one channel carrying everything is a risk. The
+ * second is that none of it can produce a NaN, an Infinity or a confident zero
+ * from missing data, because a scoreboard that ranks a dead property above a
+ * live one is worse than no scoreboard.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ CONCENTRATION_FLOOR,
+ MIN_SAMPLE_HUMANS,
+ RISK_WEIGHTS,
+ TARGET_RPM_USD,
+ VIRAL_WEIGHTS,
+ clamp01,
+ coefficientOfVariation,
+ discoveryShare,
+ growthRate,
+ scoreSite,
+ topSourceShare,
+} from "@/lib/dashboard/score";
+
+const flat = (value: number, length = 14) => Array.from({ length }, () => value);
+
+const component = (list: { key: string; value: number | null }[], key: string) =>
+ list.find((c) => c.key === key);
+
+describe("weights", () => {
+ it("each half sums to one, so the weighted mean is a mean", () => {
+ const sum = (w: Record) => Object.values(w).reduce((a, b) => a + b, 0);
+ expect(sum(VIRAL_WEIGHTS)).toBeCloseTo(1);
+ expect(sum(RISK_WEIGHTS)).toBeCloseTo(1);
+ });
+});
+
+describe("clamp01", () => {
+ it("orders NaN to zero rather than propagating it", () => {
+ expect(clamp01(Number.NaN)).toBe(0);
+ expect(clamp01(Number.POSITIVE_INFINITY)).toBe(1);
+ expect(clamp01(-3)).toBe(0);
+ expect(clamp01(0.4)).toBe(0.4);
+ });
+});
+
+describe("growthRate", () => {
+ it("compares the recent half of the window with the earlier half", () => {
+ expect(growthRate([1, 1, 2, 2])).toBeCloseTo(1);
+ expect(growthRate([2, 2, 1, 1])).toBeCloseTo(-0.5);
+ expect(growthRate([5, 5, 5, 5])).toBeCloseTo(0);
+ });
+
+ it("treats growth from nothing as growth rather than dividing by zero", () => {
+ const g = growthRate([0, 0, 3, 4]);
+ expect(g).not.toBeNull();
+ expect(Number.isFinite(g as number)).toBe(true);
+ expect(g).toBeGreaterThan(0);
+ });
+
+ it("is null with too few buckets, or with no traffic at all", () => {
+ expect(growthRate([1, 2, 3])).toBeNull();
+ expect(growthRate([])).toBeNull();
+ expect(growthRate([0, 0, 0, 0])).toBeNull();
+ });
+});
+
+describe("coefficientOfVariation", () => {
+ it("is zero for a flat series and larger for a spiky one", () => {
+ expect(coefficientOfVariation(flat(10))).toBeCloseTo(0);
+ const spiky = coefficientOfVariation([0, 0, 0, 0, 0, 100]);
+ expect(spiky).not.toBeNull();
+ expect(spiky as number).toBeGreaterThan(1);
+ });
+
+ it("is scale-free: ten visits a day and ten thousand score the same", () => {
+ const small = coefficientOfVariation([1, 3, 2, 4, 2, 3]);
+ const large = coefficientOfVariation([1000, 3000, 2000, 4000, 2000, 3000]);
+ expect(small).toBeCloseTo(large as number);
+ });
+
+ it("is null with no mean to divide by", () => {
+ expect(coefficientOfVariation([0, 0, 0])).toBeNull();
+ expect(coefficientOfVariation([5, 5])).toBeNull();
+ });
+});
+
+describe("discoveryShare", () => {
+ it("counts channels a stranger can arrive through, not the ones we had", () => {
+ const share = discoveryShare([
+ { label: "Search · google", value: 40 },
+ { label: "Social · reddit", value: 10 },
+ { label: "Direct", value: 50 },
+ ]);
+ expect(share).toBeCloseTo(0.5);
+ });
+
+ it("reads the bucket spelling as well as the rendered label", () => {
+ expect(discoveryShare([{ label: "ai_referral:chatgpt", value: 3 }])).toBeCloseTo(1);
+ expect(discoveryShare([{ label: "AI · chatgpt", value: 3 }])).toBeCloseTo(1);
+ });
+
+ it("is null rather than zero when nothing arrived", () => {
+ expect(discoveryShare([])).toBeNull();
+ expect(discoveryShare(undefined)).toBeNull();
+ expect(discoveryShare([{ label: "Direct", value: 0 }])).toBeNull();
+ });
+});
+
+describe("topSourceShare", () => {
+ it("is the largest channel's share of everything", () => {
+ expect(topSourceShare([{ label: "a", value: 9 }, { label: "b", value: 1 }])).toBeCloseTo(0.9);
+ expect(topSourceShare([{ label: "a", value: 1 }])).toBeCloseTo(1);
+ expect(topSourceShare([])).toBeNull();
+ });
+});
+
+describe("scoreSite", () => {
+ const growing = () => ({
+ humans: [5, 5, 6, 6, 10, 12, 14, 16],
+ bots: [1, 1, 1, 1, 1, 1, 1, 1],
+ sources: [
+ { label: "Search · google", value: 40 },
+ { label: "Social · reddit", value: 30 },
+ { label: "Direct", value: 30 },
+ ],
+ mixKnown: true,
+ });
+
+ it("scores a growing, human, well-spread property above a shrinking one", () => {
+ const up = scoreSite(growing());
+ const down = scoreSite({ ...growing(), humans: [16, 14, 12, 10, 6, 6, 5, 5] });
+ expect(up.score).not.toBeNull();
+ expect(down.score).not.toBeNull();
+ expect(up.score as number).toBeGreaterThan(down.score as number);
+ });
+
+ it("keeps every score inside 0..100", () => {
+ for (const input of [
+ growing(),
+ { humans: [] },
+ { humans: flat(0) },
+ { humans: [1e12, 1e12, 0, 0], bots: flat(1e12), mixKnown: true },
+ { humans: flat(5), revenueUsd: 1e9, mixKnown: true },
+ ]) {
+ const model = scoreSite(input);
+ if (model.score === null) continue;
+ expect(model.score).toBeGreaterThanOrEqual(0);
+ expect(model.score).toBeLessThanOrEqual(100);
+ }
+ });
+
+ it("marks a mostly-crawler property down through humanity and bot dependence", () => {
+ const human = scoreSite(growing());
+ const crawled = scoreSite({ ...growing(), bots: [500, 500, 500, 500, 500, 500, 500, 500] });
+ expect(component(crawled.viralComponents, "humanity")?.value as number).toBeLessThan(0.1);
+ expect(component(crawled.riskComponents, "botDependence")?.value as number).toBeGreaterThan(0.9);
+ expect(crawled.score as number).toBeLessThan(human.score as number);
+ });
+
+ it("does not assume a 100% human property when the mix was never measured", () => {
+ const model = scoreSite({ ...growing(), bots: [], mixKnown: false });
+ expect(component(model.viralComponents, "humanity")?.value).toBeNull();
+ expect(component(model.riskComponents, "botDependence")?.value).toBeNull();
+ expect(model.notes.join(" ")).toMatch(/unknown/i);
+ // Dropped, not zeroed: the remaining weight still adds up to a mean.
+ expect(model.coverage).toBeLessThan(1);
+ expect(model.score).not.toBeNull();
+ });
+
+ it("treats one channel carrying everything as a risk, an even split as none", () => {
+ const oneChannel = scoreSite({
+ ...growing(),
+ sources: [{ label: "Search · google", value: 100 }],
+ });
+ const spread = scoreSite({
+ ...growing(),
+ sources: [
+ { label: "Search · google", value: 34 },
+ { label: "Social · reddit", value: 33 },
+ { label: "Referral · news.ycombinator.com", value: 33 },
+ ],
+ });
+ expect(component(oneChannel.riskComponents, "concentration")?.value).toBeCloseTo(1);
+ expect(component(spread.riskComponents, "concentration")?.value).toBeCloseTo(0);
+ expect(spread.score as number).toBeGreaterThan(oneChannel.score as number);
+ });
+
+ it("puts the concentration floor where an even-ish split stops counting", () => {
+ const atFloor = scoreSite({
+ ...growing(),
+ sources: [
+ { label: "Search · google", value: CONCENTRATION_FLOOR * 100 },
+ { label: "Direct", value: (1 - CONCENTRATION_FLOOR) * 100 },
+ ],
+ });
+ expect(component(atFloor.riskComponents, "concentration")?.value).toBeCloseTo(0);
+ });
+
+ it("scores money against the target RPM and caps it there", () => {
+ const humans = 1000;
+ const atTarget = scoreSite({ humans: flat(humans / 14), revenueUsd: TARGET_RPM_USD, mixKnown: true });
+ const over = scoreSite({ humans: flat(humans / 14), revenueUsd: TARGET_RPM_USD * 50, mixKnown: true });
+ expect(component(atTarget.viralComponents, "money")?.value).toBeCloseTo(1, 1);
+ expect(component(over.viralComponents, "money")?.value).toBe(1);
+ expect(component(over.riskComponents, "unmonetised")?.value).toBe(0);
+ });
+
+ it("flags a sample too small to lean on without hiding the number", () => {
+ const tiny = scoreSite({ humans: [1, 1, 2, 1], mixKnown: true, bots: [0, 0, 0, 0] });
+ expect(tiny.provisional).toBe(true);
+ expect(tiny.humans).toBeLessThan(MIN_SAMPLE_HUMANS);
+ expect(tiny.score).not.toBeNull();
+ expect(tiny.notes.join(" ")).toContain(String(MIN_SAMPLE_HUMANS));
+ });
+
+ it("prefers the unfiltered totals when the series only counted one side", () => {
+ // What `who=bots` looks like: the human column is zero by construction, and
+ // the mix knows the real split.
+ const model = scoreSite({
+ humans: flat(0),
+ bots: flat(10),
+ humansTotal: 900,
+ botsTotal: 100,
+ mixKnown: true,
+ });
+ expect(model.humans).toBe(900);
+ expect(component(model.viralComponents, "humanity")?.value).toBeCloseTo(0.9);
+ expect(model.provisional).toBe(false);
+ });
+
+ it("returns no score at all, rather than a zero, for a property with no data", () => {
+ const empty = scoreSite({ humans: [] });
+ expect(empty.score).toBeNull();
+ expect(empty.viral).toBe(0);
+ expect(empty.risk).toBe(0);
+ expect(empty.coverage).toBe(0);
+ });
+
+ it("survives junk without a NaN reaching a component", () => {
+ const model = scoreSite({
+ humans: [Number.NaN, Number.POSITIVE_INFINITY, 3, 4] as number[],
+ bots: ["7" as unknown as number, null as unknown as number, 1, 1],
+ sources: [{ label: "Search · google", value: Number.NaN }, { label: "", value: 5 }] as never,
+ revenueUsd: Number.NaN,
+ mixKnown: true,
+ });
+ const values = [...model.viralComponents, ...model.riskComponents].map((c) => c.value);
+ for (const v of values) expect(v === null || Number.isFinite(v)).toBe(true);
+ expect(model.score === null || Number.isFinite(model.score)).toBe(true);
+ });
+});
diff --git a/tests/dashboard-screens.test.ts b/tests/dashboard-screens.test.ts
new file mode 100644
index 0000000..8f2f7f8
--- /dev/null
+++ b/tests/dashboard-screens.test.ts
@@ -0,0 +1,248 @@
+/**
+ * What the dashboard actually draws, and how you get from the list to a domain.
+ *
+ * Rendered rather than inspected: hqtui screens are only observable by drawing
+ * them, and the two things that bite — a panel header that drops half of itself
+ * at one width, and a table that is unreachable by the mouse because nothing
+ * registered a hit region — are invisible in source review.
+ *
+ * The navigation is tested through `handleKey`, the same function the app binds
+ * its key handler to, so what passes here is what the terminal does.
+ */
+import { describe, expect, it } from "vitest";
+import { renderToScreen } from "@profullstack/hqtui/testing";
+
+import { buildRoi } from "@/lib/dashboard/roi";
+import { buildSiteDetail } from "@/lib/dashboard/site";
+import type { DashboardSnapshot, SiteStats } from "@/lib/dashboard/collect";
+import { handleKey, initialState, renderBody, scoreText, sortSites, type State } from "@/cli/dashboard";
+
+const WIDE = { width: 160, height: 44 };
+
+function siteRow(over: Partial = {}): SiteStats {
+ return {
+ site: "nichedb.dev",
+ id: "p-1",
+ url: "https://nichedb.dev/",
+ visitors: 400,
+ pageviews: 120,
+ sources: [
+ { label: "Search · google", value: 70 },
+ { label: "Direct", value: 30 },
+ ],
+ referrers: [{ label: "news.ycombinator.com", value: 9 }],
+ pages: [{ label: "/pricing", value: 40 }],
+ series: Array.from({ length: 8 }, (_, i) => ({
+ date: `2026-09-0${i + 1}`,
+ pageviews: 10 + i * 3,
+ humans: 10 + i * 3,
+ bots: 2,
+ ai: 1,
+ })),
+ mix: { humans: 164, bots: 20, ai: 8, events: 184 },
+ ...over,
+ };
+}
+
+function snapshot(sites: SiteStats[]): DashboardSnapshot {
+ const finance = {
+ windowDays: 30,
+ businesses: [{ id: "b-1", name: "coinpayportal.com" }],
+ earnings: { commissionUsd: 500, grossVolumeUsd: 50_000 },
+ series: Array.from({ length: 30 }, () => ({ volumeUsd: 100, commissionUsd: 1 })),
+ position: {
+ lookbackDays: 180,
+ monthsObserved: 6,
+ spending: { perMonth: 4_000 },
+ scopes: [{ scope: "business", spending: 24_000, income: 600, accounts: 1 }],
+ },
+ bank: { accounts: [], ledger: [], ledgerTotal: 0 },
+ };
+ const ads = {
+ rangeDays: 30,
+ totals: { spentCents: 500, earnedCents: 700, pubImpressions: 9_000, pubClicks: 12 },
+ slots: [{ id: "s-1", name: "nichedb.dev", projectId: "p-1", impressions: 900, clicks: 3, earnedCents: 250 }],
+ campaigns: [{ id: "c-1", name: "NicheDB", url: "https://nichedb.dev/", spentCents: 120 }],
+ };
+ const roi = buildRoi({
+ traffic: { range: "1m", who: "humans", sites },
+ ads,
+ finance,
+ });
+ const window = { range: "1m", who: "humans", financeDays: 30 };
+ for (const site of sites) {
+ site.score = buildSiteDetail({ site, roi, ads, finance, window }).score;
+ }
+ return {
+ generatedAt: new Date().toISOString(),
+ window,
+ sites,
+ fleet: { sources: sites[0]?.sources ?? [], referrers: [], pages: sites[0]?.pages ?? [] },
+ ads,
+ finance,
+ roi,
+ errors: {},
+ };
+}
+
+function stateWith(over: Partial = {}, sites: SiteStats[] = [siteRow()]): State {
+ return initialState({ tab: 1, range: "1m", snapshot: snapshot(sites), ...over });
+}
+
+const draw = (state: State) =>
+ renderToScreen(({ ui, theme }) => renderBody(ui, state, theme), WIDE);
+
+describe("Traffic list", () => {
+ it("draws a Score column beside the traffic, with the site's score in it", () => {
+ const state = stateWith();
+ const text = draw(state).text();
+ expect(text).toContain("Score");
+ expect(text).toContain("nichedb.dev");
+ expect(text).toContain(scoreText(state.snapshot?.sites[0] as SiteStats));
+ });
+
+ it("says which order it is in and how to change it", () => {
+ const text = draw(stateWith()).text();
+ expect(text).toMatch(/by score/);
+ expect(text).toMatch(/Enter opens/);
+ });
+
+ it("registers a mouse region, so a click can reach a row at all", () => {
+ const screen = draw(stateWith());
+ expect(screen.regions.length).toBeGreaterThan(0);
+ expect(screen.regions.some((r) => typeof r.onClick === "function")).toBe(true);
+ });
+
+ it("marks a site that did not answer rather than drawing it as a quiet one", () => {
+ const rows = [siteRow(), siteRow({ site: "down.dev", id: "p-2", url: "https://down.dev", error: "504 Gateway Timeout", visitors: 0, pageviews: 0, series: [], mix: undefined })];
+ const text = draw(stateWith({}, rows)).text();
+ expect(text).toContain("504");
+ expect(text).toContain("down.dev");
+ });
+});
+
+describe("sortSites", () => {
+ const rows = () => [
+ siteRow({ site: "busy.dev", id: "p-2", url: "https://busy.dev", visitors: 90_000, pageviews: 12 }),
+ siteRow(),
+ siteRow({ site: "dead.dev", id: "p-3", url: "https://dead.dev", error: "timeout", visitors: 0, pageviews: 0, series: [], mix: undefined }),
+ ];
+
+ it("puts the best score first, and a site that did not answer last", () => {
+ const sorted = sortSites(snapshot(rows()).sites, "score");
+ expect(sorted[sorted.length - 1]?.site).toBe("dead.dev");
+ expect(sorted[0]?.score?.score).not.toBeNull();
+ });
+
+ it("ranks the busiest site first by visitors and not necessarily by score", () => {
+ const sites = snapshot(rows()).sites;
+ expect(sortSites(sites, "visitors")[0]?.site).toBe("busy.dev");
+ expect(sortSites(sites, "pageviews")[0]?.site).toBe("nichedb.dev");
+ });
+});
+
+describe("opening a domain", () => {
+ const noop = { refresh: () => {} };
+
+ it("Enter on the list opens the selected property", () => {
+ const state = stateWith();
+ expect(handleKey(state, { name: "enter" }, noop)).toBe(true);
+ expect(state.domain).toBe("nichedb.dev");
+ });
+
+ it("Esc, ← and 2 all come back to the list", () => {
+ for (const key of ["escape", "backspace", "left", "2"]) {
+ const state = stateWith({ domain: "nichedb.dev" });
+ expect(handleKey(state, { name: key }, noop)).toBe(true);
+ expect(state.domain, key).toBeNull();
+ }
+ });
+
+ it("Esc on the list itself is not swallowed", () => {
+ expect(handleKey(stateWith(), { name: "escape" }, noop)).toBe(false);
+ });
+
+ it("↑/↓ move the selection rather than only the scroll", () => {
+ const rows = [siteRow(), siteRow({ site: "second.dev", id: "p-2", url: "https://second.dev" })];
+ const state = stateWith({ sort: "visitors" }, rows);
+ draw(state); // the pane learns how many rows there are by being drawn
+ handleKey(state, { name: "down" }, noop);
+ expect(state.panes.sites?.selected).toBe(1);
+ handleKey(state, { name: "enter" }, noop);
+ expect(state.domain).toBe(sortSites(state.snapshot?.sites ?? [], "visitors")[1]?.site);
+ });
+
+ it("keeps the highlight on the same property when the order changes", () => {
+ const rows = [
+ siteRow({ site: "busy.dev", id: "p-2", url: "https://busy.dev", visitors: 90_000, pageviews: 5 }),
+ siteRow(),
+ ];
+ const state = stateWith({ sort: "visitors" }, rows);
+ draw(state);
+ handleKey(state, { name: "down" }, noop);
+ const held = sortSites(state.snapshot?.sites ?? [], "visitors")[1]?.site;
+ handleKey(state, { name: "s" }, noop);
+ const nowAt = sortSites(state.snapshot?.sites ?? [], state.sort)[state.panes.sites?.selected ?? 0]?.site;
+ expect(nowAt).toBe(held);
+ });
+});
+
+describe("the domain screen", () => {
+ const text = (over: Partial = {}) =>
+ draw(stateWith({ domain: "nichedb.dev" }, [siteRow(over)])).text();
+
+ it("leads with the domain and its own traffic", () => {
+ const out = text();
+ expect(out).toContain("nichedb.dev");
+ expect(out).toContain("Pageviews");
+ expect(out).toContain("Human share");
+ expect(out).toContain("AI referrals");
+ });
+
+ it("shows the money for that domain, cost by both denominators", () => {
+ const out = text();
+ expect(out).toContain("Cost · by views");
+ expect(out).toContain("Cost · by visits");
+ expect(out).toContain("Ad earned");
+ });
+
+ it("shows the score and every component behind it", () => {
+ const out = text();
+ expect(out).toContain("Risk-to-viral");
+ expect(out).toContain("viral");
+ expect(out).toContain("risk");
+ for (const part of ["Momentum", "Discovery", "Humanity", "Money", "Volatility", "Bot dependence"]) {
+ expect(out, part).toContain(part);
+ }
+ // The formula itself, so nobody has to trust the number.
+ expect(out).toMatch(/100 × viral/);
+ });
+
+ it("prints a dash and a reason rather than a zero it cannot stand behind", () => {
+ const out = text();
+ expect(out).toMatch(/Revenue\s+—/);
+ expect(out).toMatch(/Earn-rail/);
+ });
+
+ it("says so when the site is one that did not answer", () => {
+ const out = text({ error: "504 Gateway Timeout", series: [], mix: undefined, visitors: 0, pageviews: 0 });
+ expect(out).toContain("504 Gateway Timeout");
+ expect(out).toContain("missing, not zero");
+ });
+
+ it("says so when the domain is no longer in the snapshot", () => {
+ const state = stateWith({ domain: "vanished.dev" });
+ expect(draw(state).text()).toContain("not in the current snapshot");
+ });
+
+ // hqtui draws a panel's title and its subtitle in the same border row and the
+ // subtitle wins, so a subtitle sized against the whole pane costs the panel
+ // its own name at exactly the widths nobody renders in a test fixture.
+ it("keeps every panel's name at a narrow terminal", () => {
+ const state = stateWith({ domain: "nichedb.dev" });
+ const narrow = renderToScreen(({ ui, theme }) => renderBody(ui, state, theme), { width: 96, height: 30 });
+ for (const title of ["nichedb.dev", "Money", "Risk-to-viral", "Why it scores that"]) {
+ expect(narrow.text(), title).toContain(title);
+ }
+ });
+});
diff --git a/tests/dashboard-site.test.ts b/tests/dashboard-site.test.ts
new file mode 100644
index 0000000..6a74c89
--- /dev/null
+++ b/tests/dashboard-site.test.ts
@@ -0,0 +1,228 @@
+/**
+ * One property's own numbers.
+ *
+ * The joins are the whole point of this module and each of them is a different
+ * one — earnings by project id, spend by destination host, commission only when
+ * there is exactly one business to attribute it to — so each is pinned here,
+ * along with the refusals: what this cannot attribute it must say rather than
+ * divide out.
+ */
+import { describe, expect, it } from "vitest";
+
+import { buildRoi, type AdsInput, type FinanceInput } from "@/lib/dashboard/roi";
+import {
+ adMoneyForSite,
+ buildSiteDetail,
+ coinpayRevenueForSite,
+ hostFrom,
+ sameProperty,
+ type SiteLike,
+} from "@/lib/dashboard/site";
+
+const site = (over: Partial = {}): SiteLike => ({
+ site: "nichedb.dev",
+ id: "p-1",
+ url: "https://nichedb.dev/",
+ visitors: 400,
+ pageviews: 100,
+ sources: [
+ { label: "Search · google", value: 60 },
+ { label: "Direct", value: 40 },
+ ],
+ referrers: [],
+ pages: [{ label: "/", value: 80 }],
+ series: Array.from({ length: 8 }, (_, i) => ({
+ date: `2026-09-0${i + 1}`,
+ pageviews: 10 + i,
+ humans: 10 + i,
+ bots: 2,
+ ai: 1,
+ })),
+ mix: { humans: 116, bots: 16, ai: 8, events: 132 },
+ ...over,
+});
+
+const ads = (): AdsInput => ({
+ rangeDays: 30,
+ totals: { spentCents: 500, earnedCents: 700, pubImpressions: 10_000, pubClicks: 20 },
+ slots: [
+ { id: "s-1", name: "nichedb.dev", projectId: "p-1", impressions: 900, clicks: 3, earnedCents: 250 },
+ { id: "s-2", name: "other.dev", projectId: "p-2", impressions: 50, clicks: 0, earnedCents: 400 },
+ ],
+ campaigns: [
+ { id: "c-1", name: "NicheDB", url: "https://www.nichedb.dev/pricing", spentCents: 120 },
+ { id: "c-2", name: "Other", url: "https://other.dev/", spentCents: 999 },
+ { id: "c-3", name: "Broken", url: null, spentCents: 777 },
+ ],
+});
+
+const finance = (businesses: Array<{ id?: string; name?: string }>): FinanceInput => ({
+ windowDays: 30,
+ businesses,
+ earnings: { commissionUsd: 900, grossVolumeUsd: 90_000 },
+ series: Array.from({ length: 30 }, () => ({ volumeUsd: 100, commissionUsd: 1 })),
+ position: {
+ lookbackDays: 180,
+ monthsObserved: 6,
+ spending: { perMonth: 3_000 },
+ scopes: [{ scope: "business", spending: 18_000, income: 600, accounts: 1 }],
+ },
+ bank: { accounts: [], ledger: [], ledgerTotal: 0 },
+});
+
+const roiFor = (sites: SiteLike[], fin: FinanceInput | null = finance([])) =>
+ buildRoi({
+ traffic: {
+ range: "1m",
+ who: "humans",
+ sites: sites.map((s) => ({ site: s.site, visitors: s.visitors, pageviews: s.pageviews })),
+ },
+ ads: ads(),
+ finance: fin,
+ });
+
+describe("hostFrom", () => {
+ it("takes a host out of a URL, a bare host, or neither", () => {
+ expect(hostFrom("https://www.NicheDB.dev/pricing?x=1")).toBe("nichedb.dev");
+ expect(hostFrom("nichedb.dev")).toBe("nichedb.dev");
+ expect(hostFrom("")).toBeNull();
+ expect(hostFrom(null)).toBeNull();
+ expect(hostFrom(" ")).toBeNull();
+ });
+});
+
+describe("sameProperty", () => {
+ it("matches on the project URL, on the project name, and ignores www", () => {
+ expect(sameProperty(site(), "https://www.nichedb.dev/a")).toBe(true);
+ expect(sameProperty(site({ url: undefined }), "https://nichedb.dev/")).toBe(true);
+ expect(sameProperty(site(), "https://other.dev/")).toBe(false);
+ expect(sameProperty(site(), null)).toBe(false);
+ });
+});
+
+describe("adMoneyForSite", () => {
+ it("takes earnings by project id and spend by where the campaign points", () => {
+ const money = adMoneyForSite(ads(), site());
+ expect(money.earnedUsd).toBeCloseTo(2.5);
+ expect(money.spentUsd).toBeCloseTo(1.2);
+ expect(money.impressions).toBe(900);
+ expect(money.clicks).toBe(3);
+ });
+
+ it("is all zeros, not a share of the fleet, when nothing matches", () => {
+ const money = adMoneyForSite(ads(), site({ id: "p-9", site: "nobody.dev", url: "https://nobody.dev" }));
+ expect(money).toEqual({ earnedUsd: 0, spentUsd: 0, impressions: 0, clicks: 0 });
+ });
+
+ it("survives an absent ads feed", () => {
+ expect(adMoneyForSite(null, site()).earnedUsd).toBe(0);
+ });
+});
+
+describe("coinpayRevenueForSite", () => {
+ it("attributes the whole commission when there is exactly one matching business", () => {
+ const fin = finance([{ id: "b-1", name: "nichedb.dev" }]);
+ const result = coinpayRevenueForSite(fin, roiFor([site()], fin), site());
+ expect(result.usd).toBeGreaterThan(0);
+ expect(result.basis).toContain("nichedb.dev");
+ });
+
+ it("refuses rather than sharing it out across several businesses", () => {
+ const fin = finance([{ name: "a" }, { name: "b" }]);
+ const result = coinpayRevenueForSite(fin, roiFor([site()], fin), site());
+ expect(result.usd).toBeNull();
+ expect(result.basis).toMatch(/no per-business split/);
+ });
+
+ it("refuses when the one business is a different property", () => {
+ const fin = finance([{ name: "coinpayportal.com" }]);
+ expect(coinpayRevenueForSite(fin, roiFor([site()], fin), site()).usd).toBeNull();
+ });
+
+ it("says there is no session rather than reporting zero", () => {
+ expect(coinpayRevenueForSite(null, roiFor([site()], null), site()).basis).toMatch(/no CoinPay session/);
+ });
+});
+
+describe("buildSiteDetail", () => {
+ const build = (over: Partial = {}, fin: FinanceInput | null = finance([])) => {
+ const rows = [site(over), site({ site: "other.dev", id: "p-2", url: "https://other.dev", visitors: 600, pageviews: 900 })];
+ return buildSiteDetail({
+ site: rows[0] as SiteLike,
+ roi: roiFor(rows, fin),
+ ads: ads(),
+ finance: fin,
+ window: { range: "1m", who: "humans", financeDays: 30 },
+ });
+ };
+
+ it("prorates cost by both denominators, because they disagree by orders of magnitude", () => {
+ const detail = build();
+ // 100 of 1,000 pageviews, but 400 of 1,000 visits.
+ expect(detail.traffic.viewShare).toBeCloseTo(0.1);
+ expect(detail.traffic.visitShare).toBeCloseTo(0.4);
+ expect(detail.money.costByVisitsUsd as number).toBeGreaterThan(detail.money.costByViewsUsd as number);
+ });
+
+ it("reads the human / bot split from the unfiltered mix, not from the filtered series", () => {
+ const detail = build();
+ expect(detail.traffic.mixKnown).toBe(true);
+ expect(detail.traffic.humans).toBe(116);
+ expect(detail.traffic.bots).toBe(16);
+ expect(detail.traffic.humanShare as number).toBeCloseTo(116 / 132);
+ });
+
+ it("says the mix is unknown rather than calling a site 100% human", () => {
+ const detail = build({ mix: undefined });
+ expect(detail.traffic.mixKnown).toBe(false);
+ expect(detail.traffic.humanShare).toBeNull();
+ expect(detail.gaps.join(" ")).toMatch(/mix is missing/i);
+ });
+
+ it("names the earn rail as pooled rather than leaving a money line blank", () => {
+ expect(build().gaps.join(" ")).toMatch(/Earn-rail/);
+ });
+
+ it("has no revenue, no RPM and no net when revenue cannot be attributed", () => {
+ const detail = build();
+ expect(detail.money.revenueUsd).toBeNull();
+ expect(detail.money.rpmUsd).toBeNull();
+ expect(detail.money.netUsd).toBeNull();
+ expect(detail.gaps.join(" ")).toMatch(/not attributable/);
+ });
+
+ it("computes revenue per 1k humans once there is revenue to divide", () => {
+ const fin = finance([{ name: "nichedb.dev" }]);
+ const detail = build({}, fin);
+ expect(detail.money.revenueUsd as number).toBeGreaterThan(0);
+ expect(detail.money.rpmUsd as number).toBeCloseTo(((detail.money.revenueUsd as number) * 1000) / 116);
+ expect(Number.isFinite(detail.money.netUsd as number)).toBe(true);
+ });
+
+ it("carries a failed site through as missing rather than as a quiet day", () => {
+ const detail = build({ error: "504 Gateway Timeout", series: [], mix: undefined, visitors: 0, pageviews: 0 });
+ expect(detail.error).toBe("504 Gateway Timeout");
+ expect(detail.score.score).toBeNull();
+ });
+
+ it("scores the property and keeps it in range", () => {
+ const detail = build();
+ expect(detail.score.score).not.toBeNull();
+ expect(detail.score.score as number).toBeGreaterThanOrEqual(0);
+ expect(detail.score.score as number).toBeLessThanOrEqual(100);
+ expect(detail.score.viralComponents).toHaveLength(4);
+ expect(detail.score.riskComponents).toHaveLength(4);
+ });
+
+ it("says a bots-only window cannot carry momentum", () => {
+ const rows = [site()];
+ const detail = buildSiteDetail({
+ site: rows[0] as SiteLike,
+ roi: roiFor(rows),
+ ads: ads(),
+ finance: finance([]),
+ window: { range: "1m", who: "bots", financeDays: 30 },
+ });
+ expect(detail.gaps.join(" ")).toMatch(/bots-only/);
+ });
+});
diff --git a/tests/tracker-api-stats.test.ts b/tests/tracker-api-stats.test.ts
index 7099958..b8985dc 100644
--- a/tests/tracker-api-stats.test.ts
+++ b/tests/tracker-api-stats.test.ts
@@ -8,7 +8,8 @@
*/
import { describe, expect, it } from "vitest";
-import { resolveProject, totalsFromSeries } from "@/lib/tracker/apiStats";
+import { mixFromSeries, resolveProject, seriesPoints, totalsFromSeries } from "@/lib/tracker/apiStats";
+import { parseDetail } from "@/app/api/tracker/v1/stats/route";
type Row = { id: string; name: string; url: string };
@@ -97,3 +98,45 @@ describe("totalsFromSeries", () => {
expect(totalsFromSeries({ points: [{ pageviews: "4" }] } as never)).toEqual({ visitors: 0, pageviews: 4 });
});
});
+
+describe("seriesPoints", () => {
+ it("trims a series payload to what a client can plot", () => {
+ expect(
+ seriesPoints({
+ points: [{ date: "2026-09-01", pageviews: "4", humans: 3, bots: 1, ai: 2, interactions: 9 }],
+ } as never),
+ ).toEqual([{ date: "2026-09-01", pageviews: 4, humans: 3, bots: 1, ai: 2 }]);
+ });
+
+ it("is empty for a list payload or nothing, rather than throwing", () => {
+ expect(seriesPoints(undefined)).toEqual([]);
+ expect(seriesPoints([] as never)).toEqual([]);
+ });
+});
+
+describe("mixFromSeries", () => {
+ it("sums both sides, which is the only honest place a human share comes from", () => {
+ expect(
+ mixFromSeries({
+ points: [
+ { humans: 3, bots: 7, ai: 1, events: 10 },
+ { humans: 2, bots: 8, ai: 0, events: 10 },
+ ],
+ } as never),
+ ).toEqual({ humans: 5, bots: 15, ai: 1, events: 20 });
+ });
+
+ it("is zeros for nothing at all, never NaN", () => {
+ expect(mixFromSeries(undefined)).toEqual({ humans: 0, bots: 0, ai: 0, events: 0 });
+ });
+});
+
+describe("parseDetail", () => {
+ it("only an affirmative buys the extra query", () => {
+ expect(parseDetail("1")).toBe(true);
+ expect(parseDetail("true")).toBe(true);
+ expect(parseDetail(null)).toBe(false);
+ expect(parseDetail("0")).toBe(false);
+ expect(parseDetail("; drop table")).toBe(false);
+ });
+});