diff --git a/README.md b/README.md index 20f3bb8..4f91fb0 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ echo '{ "token": "crp_…" }' > ~/.crawlproof.json && chmod 600 ~/.crawlproof.js crawlproof dashboard # five live screens: ROI, Traffic, Ads, Money, Spend crawlproof dashboard --range=1m --json | jq .roi.derived +crawlproof dashboard --json | jq '.sites[] | {site, score: .score.score}' # ranked properties crawlproof stats example.com # sources, referrers, top pages as text crawlproof ad https://example.com/launch crawlproof ads pause crawlproof-ad-144 @@ -154,12 +155,21 @@ esbuild-bundles `lib/dashboard/*` and `cli/dashboard.ts` where they already live and where the test suite already covers them, leaving hqtui and the CoinPay SDK external. Rebuild with `node packages/cli/build.mjs`. +On the Traffic screen, `↑`/`↓` pick a property and `Enter` (or a click) opens +it: that domain's traffic and money on their own, and its **risk-to-viral +score** taken apart into the components it was built from — +`100 × viral × (1 − risk/2)`, where viral is momentum, discovery, humanity and +money, and risk is volatility, channel concentration, bot dependence and being +unmonetised. A component with no data is dropped rather than counted as a zero. +The full table is in [`packages/cli/README.md`](packages/cli/README.md) and the +formula lives in [`lib/dashboard/score.ts`](lib/dashboard/score.ts). + The endpoints underneath are the same ones anything else can call: | Route | Answers | | --- | --- | | `GET /api/tracker/v1/sites` | the projects this token can read | -| `GET /api/tracker/v1/stats?site=&range=&who=` | who arrived and from where | +| `GET /api/tracker/v1/stats?site=&range=&who=&detail=1` | who arrived and from where; `detail=1` adds the series and the unfiltered human / bot mix | | `GET /api/ads/v1/earnings?days=` | ad delivery and money, both sides | | `GET/POST /api/ads/v1/campaigns` | list and run campaigns | | `GET/POST /api/ads/v1/slots` | list and create publisher slots | diff --git a/app/(marketing)/docs/cli/page.tsx b/app/(marketing)/docs/cli/page.tsx index 584a306..7409a8f 100644 --- a/app/(marketing)/docs/cli/page.tsx +++ b/app/(marketing)/docs/cli/page.tsx @@ -75,6 +75,75 @@ crawlproof dashboard --sites=example.com,blog.example.com`} crawlproof dashboard --json | jq '.sites[] | {site, visitors}'`} +
+

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: pageviews, visits, humans against + bots, AI referrals and where they arrived from, next to the burn + prorated onto it, its ad earnings and spend, and the commission when + there is exactly one merchant business to attribute it to.{" "} + Esc comes back. +

+ + {`crawlproof dashboard --sort=score +crawlproof dashboard --json | jq '.sites[] | {site, score: .score.score}'`} + +
+ +
+

The risk-to-viral score

+

+ Every property is scored out of 100, shown as a column on the Traffic + list and taken apart on the domain screen. It is arithmetic over + numbers already on the screen, not a model, so a high score can always + be traced to the figure that caused it. +

+
{`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`}
+ +

+ A component with no data behind it is dropped and its weight + redistributed, never counted as a zero. A trailing{" "} + ~ marks fewer than 25 human visits + in the window, and a site whose stats call failed is not scored at + all — missing is not the same as bad. +

+
+

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); + }); +});