diff --git a/app/api/ads/v1/earnings/route.ts b/app/api/ads/v1/earnings/route.ts new file mode 100644 index 0000000..629501c --- /dev/null +++ b/app/api/ads/v1/earnings/route.ts @@ -0,0 +1,38 @@ +// /api/ads/v1/earnings — the account's ad money and delivery, for a token caller. +// +// GET ?days=7|30|90|365 +// +// The same model /dashboard/ads/earnings renders, for something holding an API +// token. It exists because the alternative for a client that wants fleet totals +// is one /campaigns/[id] request per campaign, and the account is past 170 of +// them — see `crawlproof dashboard`, which polls this on a timer. +// +// Same auth as the rest of /api/ads/v1/*: `Authorization: Bearer crp_…`. + +import { NextResponse, type NextRequest } from "next/server"; + +import { serviceClient } from "@/lib/supabase/service"; +import { authenticateBearer } from "@/lib/sp/apiAuth"; +import { loadEarnings } from "@/lib/ads/earnings-data"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const ALLOWED_DAYS = [7, 30, 90, 365]; + +/** An unknown window falls back to 30 rather than reaching the query planner. */ +export function parseDays(raw: string | null): number { + const n = Number(raw); + return ALLOWED_DAYS.includes(n) ? n : 30; +} + +export async function GET(req: NextRequest) { + const auth = await authenticateBearer(req); + if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const days = parseDays(req.nextUrl.searchParams.get("days")); + // The service client has no RLS. loadEarnings filters every table by + // owner_id itself, which is what makes passing it here safe. + const model = await loadEarnings(serviceClient(), auth.userId, days); + return NextResponse.json(model); +} diff --git a/app/api/tracker/v1/sites/route.ts b/app/api/tracker/v1/sites/route.ts new file mode 100644 index 0000000..b7a7e90 --- /dev/null +++ b/app/api/tracker/v1/sites/route.ts @@ -0,0 +1,33 @@ +// /api/tracker/v1/sites — the projects this token can read stats for. +// +// GET → { sites: [{ id, name, url, tracker_enabled }] } +// +// /stats already names them, but only inside the 400 it returns when the +// account has more than one and the caller did not say which. A client that +// wants the whole fleet should not have to parse an error message to find it. + +import { NextResponse, type NextRequest } from "next/server"; + +import { serviceClient } from "@/lib/supabase/service"; +import { authenticateBearer } from "@/lib/sp/apiAuth"; +import { listProjects } from "@/lib/tracker/apiStats"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const auth = await authenticateBearer(req); + if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const listed = await listProjects(serviceClient(), auth.userId); + if (!listed.ok) return NextResponse.json({ error: listed.error }, { status: listed.status }); + + return NextResponse.json({ + sites: listed.projects.map((p) => ({ + id: p.id, + name: p.name, + url: p.url, + tracker_enabled: p.tracker_enabled ?? null, + })), + }); +} diff --git a/cli/dashboard.ts b/cli/dashboard.ts new file mode 100644 index 0000000..6033497 --- /dev/null +++ b/cli/dashboard.ts @@ -0,0 +1,856 @@ +// `crawlproof dashboard` — what the fleet costs and what it returns, live. +// +// Five screens over three feeds: CrawlProof's tracker for who arrived, its ad +// network for what was delivered, and CoinPay for what the bank actually did. +// The interesting screen is the first one, because it is the only place those +// three meet and the only place the answer is a ratio rather than a total. +// +// Built on @profullstack/hqtui, the same library behind `coinpay finances`. + +import type { Container, RenderArgs, Theme } from "@profullstack/hqtui"; + +import { collectDashboard, type CoinPayAuth, type DashboardSnapshot } from "../lib/dashboard/collect"; + +export const TABS = ["ROI", "Traffic", "Ads", "Money", "Spend"] as const; +export const RANGES = ["1h", "4h", "1d", "1w", "1m"] as const; + +/** Which tracker range pairs with which CoinPay window. */ +export const FINANCE_DAYS: Record = { + "1h": 7, + "4h": 7, + "1d": 7, + "1w": 7, + "1m": 30, +}; + +// ── formatting ── + +const num = (v: unknown): number => { + const x = Number(v); + return Number.isFinite(x) ? x : 0; +}; + +export function money(value: unknown, { compact = false, cents = false } = {}): string { + const v = num(value); + const abs = Math.abs(v); + const sign = v < 0 ? "-" : ""; + if (compact && abs >= 1_000_000) return `${sign}$${(abs / 1_000_000).toFixed(1)}M`; + if (compact && abs >= 1_000) return `${sign}$${(abs / 1_000).toFixed(1)}k`; + const digits = cents || abs < 10 ? 2 : abs < 1_000 ? 2 : 0; + return `${sign}$${abs.toLocaleString("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + })}`; +} + +export function count(value: unknown): string { + return num(value).toLocaleString("en-US"); +} + +export function pct(value: number | null | undefined, digits = 1): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + return `${(value * 100).toFixed(digits)}%`; +} + +/** A ratio people can act on, rather than a decimal they have to convert. */ +export function ratio(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(0)}%`; +} + +export function ago(at: Date | string | null): string { + if (!at) return "never"; + const then = at instanceof Date ? at.getTime() : Date.parse(at); + if (!Number.isFinite(then)) return "never"; + const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (secs < 60) return `${secs}s ago`; + if (secs < 3600) return `${Math.round(secs / 60)}m ago`; + return `${Math.round(secs / 3600)}h ago`; +} + +const clock = () => new Date().toLocaleTimeString("en-US", { hour12: false }); + +// ── state ── + +type Pane = { selected: number; offset: number; total: number }; + +type State = { + tab: number; + range: string; + who: string; + snapshot: DashboardSnapshot | null; + loading: boolean; + lastRefresh: Date | null; + error: string | null; + paused: boolean; + showHelp: boolean; + panes: Record; +}; + +function pane(state: State, name: string, total: number): Pane { + let p = state.panes[name]; + if (!p) { + p = { selected: 0, offset: 0, total: 0 }; + state.panes[name] = p; + } + p.total = total; + const max = Math.max(0, total - 1); + p.offset = Math.min(p.offset, max); + p.selected = Math.min(p.selected, max); + return p; +} + +function scrollPane(p: Pane, delta: number, rows = 1): void { + const max = Math.max(0, p.total - 1); + p.offset = Math.max(0, Math.min(p.offset + delta * rows, max)); + p.selected = Math.max(p.offset, Math.min(p.selected, max)); +} + +const TAB_PANE = ["vendors", "sites", "campaigns", "invoices", "ledger"]; + +const signed = (theme: Theme, v: number) => (v >= 0 ? theme.success : theme.danger); + +// ── screens ── + +/** + * The one screen that answers the question in the command's name. + * + * Cost and revenue are both monthly rates, because burn is a rate; the window + * column beside them is that rate prorated onto whatever traffic window is + * selected, which is the only way the per-visitor numbers mean anything. + */ +function roiScreen(ui: Container, state: State, theme: Theme): void { + const s = state.snapshot as DashboardSnapshot; + const r = s.roi; + const win = `${state.range}`; + + ui.grid({ columns: ["1fr", "1fr", "1fr"], rows: [12, 11, "1fr"], gap: 1 }, (grid) => { + grid.panel( + { + title: "The number", + subtitle: r.cost.scopeMissing ? "whole bank feed" : "business scope", + subtitleColor: r.cost.scopeMissing ? theme.warning : theme.muted, + }, + (p) => { + p.keyValues( + [ + { label: "Cost", value: `${money(r.cost.perMonthUsd)}/mo`, color: theme.danger }, + { label: "Revenue", value: `${money(r.revenue.perMonthUsd)}/mo`, color: theme.success }, + { + label: "Net", + value: `${money(r.derived.netPerMonthUsd)}/mo`, + color: signed(theme, r.derived.netPerMonthUsd), + }, + { + label: "ROI", + value: ratio(r.derived.roi), + color: (r.derived.roi ?? -1) >= 0 ? theme.success : theme.danger, + }, + { + label: "Months of cover", + value: r.derived.monthsOfCover === null ? "—" : r.derived.monthsOfCover.toFixed(1), + color: (r.derived.monthsOfCover ?? 0) < 3 ? theme.warning : theme.success, + }, + { label: "", value: "" }, + { label: `Cost · ${win}`, value: money(r.cost.windowUsd) }, + { label: `Revenue · ${win}`, value: money(r.revenue.windowUsd) }, + ], + { labelWidth: 17 }, + ); + }, + ); + + grid.panel({ title: "Per visitor", subtitle: `${win} · ${state.who}` }, (p) => { + p.keyValues( + [ + { label: "Visitors", value: count(r.attention.visitors), color: theme.primary }, + { label: "Pageviews", value: count(r.attention.pageviews) }, + { + label: "Cost each", + value: r.derived.costPerVisitorUsd === null ? "—" : money(r.derived.costPerVisitorUsd, { cents: true }), + color: theme.danger, + }, + { + label: "Revenue each", + value: + r.derived.revenuePerVisitorUsd === null ? "—" : money(r.derived.revenuePerVisitorUsd, { cents: true }), + color: theme.success, + }, + { + label: "Cost per view", + value: + r.derived.costPerPageviewUsd === null ? "—" : money(r.derived.costPerPageviewUsd, { cents: true }), + color: theme.danger, + }, + { + label: "Break-even", + value: + r.derived.breakEvenVisitors === null + ? "—" + : `${count(Math.ceil(r.derived.breakEvenVisitors))} visitors/mo`, + color: theme.warning, + }, + { label: "", value: "" }, + { + label: "Sites reporting", + value: `${r.attention.sitesReporting} of ${r.attention.sites}`, + color: r.attention.sitesReporting < r.attention.sites ? theme.warning : theme.muted, + }, + ], + { labelWidth: 16 }, + ); + }); + + grid.panel( + { title: "Internal", subtitle: "one account, both sides", subtitleColor: theme.muted }, + (p) => { + p.text("Ad money moving between our own products.", { fg: theme.muted }); + p.text("Counted as neither cost nor revenue.", { fg: theme.muted }); + p.keyValues( + [ + { label: "Ad spend", value: money(r.internal.adSpendUsd) }, + { label: "Ad earnings", value: money(r.internal.adEarnedUsd) }, + { + label: "Net", + value: money(r.internal.netUsd), + color: Math.abs(r.internal.netUsd) < 1 ? theme.muted : theme.warning, + }, + { label: "Available", value: money(r.internal.availableUsd) }, + ], + { labelWidth: 14 }, + ); + }, + ); + + grid.panel({ title: "Where the money goes", colSpan: 2 }, (p) => { + const vendors = r.cost.vendors.slice(0, 8); + if (!vendors.length) { + p.text("No business debits in the window.", { fg: theme.muted }); + return; + } + const max = Math.max(1, ...vendors.map((v) => v.usd)); + p.meters( + vendors.map((v) => ({ + label: v.payee.slice(0, 18), + value: v.usd, + max, + text: money(v.usd, { compact: true }), + })), + { labelWidth: 19, valueWidth: 8 }, + ); + }); + + grid.panel({ title: "Reach" }, (p) => { + p.keyValues( + [ + { label: "Impressions", value: count(r.attention.impressions) }, + { label: "Clicks", value: count(r.attention.clicks) }, + { label: "CTR", value: pct(r.attention.ctr, 2) }, + { label: "", value: "" }, + { label: "Merchant volume", value: `${money(r.revenue.grossVolumePerMonthUsd, { compact: true })}/mo` }, + { label: "Our commission", value: `${money(r.revenue.commissionPerMonthUsd)}/mo`, color: theme.success }, + ], + { labelWidth: 17 }, + ); + }); + + grid.panel({ title: "Read this before quoting a number", colSpan: 3 }, (p) => { + if (!r.caveats.length) { + p.text("Every source answered and every figure is scoped as labelled.", { fg: theme.success }); + return; + } + for (const c of r.caveats) p.text(`· ${c}`, { fg: theme.warning }); + }); + }); +} + +function trafficScreen(ui: Container, state: State, theme: Theme): void { + const s = state.snapshot as DashboardSnapshot; + const rows = s.sites; + + 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", + }, + (p) => { + const view = pane(state, "sites", rows.length); + p.table({ + columns: [ + { + key: "site", + title: "Site", + width: 28, + // A site that did not answer is coloured, not silently ordinary. + color: (row: { note?: string }) => (row.note ? theme.danger : undefined), + }, + { 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 }, + ], + rows: rows.map((row) => { + const share = s.roi.attention.visitors > 0 ? row.visitors / s.roi.attention.visitors : 0; + return { + site: row.site, + 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) : "", + }; + }), + offset: view.offset, + selected: view.selected, + scrollbar: true, + onScroll: (delta: number) => scrollPane(view, delta, 3), + }); + }, + ); + + grid.cell({ gap: 1 }, (col) => { + col.panel({ title: "Where they came from" }, (p) => { + if (!s.fleet.sources.length) { + p.text("Nobody arrived in this window.", { fg: theme.muted }); + return; + } + const max = Math.max(1, ...s.fleet.sources.map((x) => x.value)); + p.meters( + s.fleet.sources.slice(0, 8).map((x) => ({ + label: x.label.slice(0, 22), + value: x.value, + max, + text: count(x.value), + })), + { labelWidth: 23, valueWidth: 7 }, + ); + }); + + col.panel({ title: "Most-read pages" }, (p) => { + if (!s.fleet.pages.length) { + p.text("No pages read in this window.", { fg: theme.muted }); + return; + } + p.keyValues( + s.fleet.pages.slice(0, 10).map((x) => ({ + label: x.label.slice(0, 30), + value: count(x.value), + })), + { labelWidth: 31 }, + ); + }); + }); + }); +} + +function adsScreen(ui: Container, state: State, theme: Theme): void { + const s = state.snapshot as DashboardSnapshot; + const ads = s.ads; + + if (!ads) { + ui.panel({ title: "Ads" }, (p) => { + p.text(s.errors.ads ?? "Ad earnings unavailable.", { fg: theme.danger }); + p.text("Press r to retry.", { fg: theme.muted }); + }); + return; + } + + const t = ads.totals ?? {}; + const spent = num(t.spentCents) / 100; + const earned = num(t.earnedCents) / 100; + + ui.grid({ columns: ["1fr", "1fr", "1fr"], rows: [13, "1fr"], gap: 1 }, (grid) => { + grid.panel({ title: "As advertiser", subtitle: `${ads.rangeDays ?? "?"}d delivery` }, (p) => { + p.keyValues( + [ + { label: "Impressions", value: count(t.advImpressions) }, + { label: "Clicks", value: count(t.advClicks) }, + { + label: "CTR", + value: pct(num(t.advImpressions) > 0 ? num(t.advClicks) / num(t.advImpressions) : null, 2), + }, + { label: "Spend (lifetime)", value: money(spent), color: theme.warning }, + ], + { labelWidth: 19 }, + ); + }); + + grid.panel({ title: "As publisher", subtitle: `${ads.rangeDays ?? "?"}d delivery` }, (p) => { + p.keyValues( + [ + { label: "Impressions", value: count(t.pubImpressions) }, + { label: "Clicks", value: count(t.pubClicks) }, + { + label: "CTR", + value: pct(num(t.pubImpressions) > 0 ? num(t.pubClicks) / num(t.pubImpressions) : null, 2), + }, + { + label: "Invalid clicks", + value: count(t.invalidClicks), + color: num(t.invalidClicks) > 0 ? theme.warning : theme.muted, + }, + { label: "Earned (lifetime)", value: money(earned), color: theme.success }, + { label: "Available", value: money(num(t.availableCents) / 100) }, + ], + { labelWidth: 19 }, + ); + }); + + grid.panel({ title: "Net of the network" }, (p) => { + p.text("We advertise on our own slots, so these", { fg: theme.muted }); + p.text("two sides are the same dollar.", { fg: theme.muted }); + p.keyValues( + [ + { label: "Spend", value: `-${money(spent)}` }, + { label: "Earned", value: `+${money(earned)}` }, + { + label: "Net", + value: money(earned - spent), + color: Math.abs(earned - spent) < 1 ? theme.muted : theme.warning, + }, + ], + { labelWidth: 12 }, + ); + if (ads.statsUnavailable) { + p.text("A delivery query failed; counts are zero-filled.", { fg: theme.danger }); + } + }); + + grid.panel({ title: "Ad-driven arrivals", colSpan: 3, subtitle: "sources bucketed as Ad · …" }, (p) => { + const adSources = s.fleet.sources.filter((x) => x.label.startsWith("Ad ·")); + if (!adSources.length) { + p.text("No arrivals attributed to an ad in this window.", { fg: theme.muted }); + return; + } + const max = Math.max(1, ...adSources.map((x) => x.value)); + p.meters( + adSources.slice(0, 10).map((x) => ({ + label: x.label.replace(/^Ad · /, "").slice(0, 24), + value: x.value, + max, + text: count(x.value), + })), + { labelWidth: 25, valueWidth: 7 }, + ); + }); + }); +} + +function moneyScreen(ui: Container, state: State, theme: Theme): void { + const s = state.snapshot as DashboardSnapshot; + const f = s.finance; + + if (!f) { + ui.panel({ title: "Money" }, (p) => { + p.text(s.errors.finance ?? "CoinPay unavailable.", { fg: theme.danger }); + p.text("`coinpay auth login` writes the session this reads.", { fg: theme.muted }); + }); + return; + } + + const e = f.earnings ?? {}; + const bank = (f as { bank?: Record }).bank ?? {}; + const cash = (bank.cashflow ?? {}) as Record; + const invoices = (f as { invoices?: { totals?: Record; counts?: Record } }) + .invoices ?? {}; + + ui.grid({ columns: ["1fr", "1fr", "1fr"], rows: [12, "1fr"], gap: 1 }, (grid) => { + grid.panel({ title: `Earnings · ${s.window.financeDays}d` }, (p) => { + p.keyValues( + [ + { label: "Gross volume", value: money(e.grossVolumeUsd), color: theme.primary }, + { label: "Commission", value: money(e.commissionUsd), color: theme.success }, + { label: "Net", value: money(e.netUsd) }, + ], + { labelWidth: 16 }, + ); + }); + + grid.panel({ title: "Bank & cards" }, (p) => { + p.keyValues( + [ + { label: "Assets", value: money(bank.assets), color: theme.success }, + { label: "Owed", value: money(bank.liabilities), color: theme.danger }, + { label: "Net", value: money(bank.net), color: signed(theme, num(bank.net)) }, + { label: `In · ${s.window.financeDays}d`, value: money(cash.moneyIn), color: theme.success }, + { label: `Out · ${s.window.financeDays}d`, value: money(cash.moneyOut), color: theme.danger }, + { label: "Accounts", value: count(bank.accountCount) }, + ], + { labelWidth: 16 }, + ); + }); + + grid.panel({ title: "Owed to us" }, (p) => { + p.keyValues( + [ + { + label: "Outstanding", + value: `${money(invoices.totals?.outstanding)} (${count(invoices.counts?.outstanding)})`, + color: theme.warning, + }, + { + label: "Overdue", + value: `${money(invoices.totals?.overdue)} (${count(invoices.counts?.overdue)})`, + color: num(invoices.counts?.overdue) > 0 ? theme.danger : theme.muted, + }, + { + label: "Paid", + value: `${money(invoices.totals?.paid)} (${count(invoices.counts?.paid)})`, + color: theme.success, + }, + { label: "Draft", value: `${money(invoices.totals?.draft)} (${count(invoices.counts?.draft)})` }, + ], + { labelWidth: 14 }, + ); + }); + + grid.panel({ title: "Income vs spending", colSpan: 3, subtitle: "by month, from the bank feed" }, (p) => { + const months = (f.position as { months?: Array> })?.months ?? []; + if (!months.length) { + p.text("Not enough bank history to plot a month.", { fg: theme.muted }); + return; + } + p.multiGraph( + [ + { values: months.map((m) => num(m.income)), color: theme.success, label: "income", fill: true }, + { values: months.map((m) => num(m.spending)), color: theme.danger, label: "spending" }, + ], + { + min: 0, + axis: true, + axisFormat: (v: number) => money(v, { compact: true }), + timeAxis: months.map((m) => String(m.month ?? "")), + legend: true, + }, + ); + }); + }); +} + +function spendScreen(ui: Container, state: State, theme: Theme): void { + const s = state.snapshot as DashboardSnapshot; + const r = s.roi; + + if (!s.finance) { + ui.panel({ title: "Spend" }, (p) => { + p.text(s.errors.finance ?? "CoinPay unavailable.", { fg: theme.danger }); + }); + return; + } + + const bank = (s.finance as { bank?: Record }).bank ?? {}; + const categories = (bank.topCategories ?? []) as Array<{ category: string | null; spent: number; count: number }>; + + ui.grid({ columns: ["3fr", "2fr"], gap: 1 }, (grid) => { + grid.panel( + { + title: "Who we pay", + subtitle: r.cost.vendorsPartial ? "newest page of the ledger" : "the whole window", + subtitleColor: r.cost.vendorsPartial ? theme.warning : theme.muted, + footer: "business accounts only", + }, + (p) => { + const rows = r.cost.vendors; + if (!rows.length) { + p.text("No business debits in the window.", { fg: theme.muted }); + return; + } + const view = pane(state, "ledger", rows.length); + p.table({ + columns: [ + { key: "payee", title: "Payee", width: 30 }, + { key: "usd", title: "Spent", align: "right", width: 12 }, + { key: "charges", title: "Charges", align: "right", width: 9 }, + { key: "share", title: "Share", align: "right", width: 8 }, + ], + rows: rows.map((v) => ({ + payee: v.payee, + usd: money(v.usd), + charges: count(v.charges), + share: pct(r.cost.perMonthUsd > 0 ? v.usd / r.cost.perMonthUsd : null, 0), + })), + offset: view.offset, + selected: view.selected, + scrollbar: true, + onScroll: (delta: number) => scrollPane(view, delta, 3), + }); + }, + ); + + grid.cell({ gap: 1 }, (col) => { + col.panel({ title: "Burn" }, (p) => { + p.keyValues( + [ + { label: "Business", value: `${money(r.cost.perMonthUsd)}/mo`, color: theme.danger }, + { label: "All accounts", value: `${money(r.cost.allScopesPerMonthUsd)}/mo`, color: theme.muted }, + { label: "", value: "" }, + { label: "Revenue", value: `${money(r.revenue.perMonthUsd)}/mo`, color: theme.success }, + { + label: "Net", + value: `${money(r.derived.netPerMonthUsd)}/mo`, + color: signed(theme, r.derived.netPerMonthUsd), + }, + ], + { labelWidth: 15 }, + ); + }); + + col.panel({ title: "By category", subtitle: "all accounts" }, (p) => { + if (!categories.length) { + p.text("Nothing categorised yet.", { fg: theme.muted }); + return; + } + const max = Math.max(1, ...categories.map((c) => num(c.spent))); + p.meters( + categories.slice(0, 9).map((c) => ({ + label: (c.category ?? "uncategorised").slice(0, 16), + value: num(c.spent), + max, + text: money(c.spent, { compact: true }), + })), + { labelWidth: 17, valueWidth: 8 }, + ); + }); + }); + }); +} + +const SCREENS = [roiScreen, trafficScreen, adsScreen, moneyScreen, spendScreen]; + +// ── app ── + +async function loadHqtui(): Promise { + try { + return await import("@profullstack/hqtui"); + } catch (err) { + const [major, minor] = process.versions.node.split(".").map(Number); + const tooOld = (major ?? 0) < 22 || ((major ?? 0) === 22 && (minor ?? 0) < 6); + const message = tooOld + ? `The dashboard needs Node 22.6 or newer (you have ${process.versions.node}).` + : `Could not load @profullstack/hqtui: ${(err as Error)?.message ?? err}`; + throw new Error(`${message} Use \`crawlproof stats\` for a plain-text answer.`); + } +} + +export type DashboardOptions = { + baseUrl: string; + token: string; + range?: string; + who?: string; + interval?: number; + concurrency?: number; + coinpay: CoinPayAuth | null; + only?: string[] | null; + theme?: string; +}; + +export async function runDashboard(opts: DashboardOptions): Promise { + const hqtui = await loadHqtui(); + const app = await hqtui.createApp({ + fps: 30, + theme: (opts.theme as never) || "dark", + quitKeys: ["ctrl+c", "q"], + }); + + const state: State = { + tab: 0, + 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: {}, + }; + + let refreshing = false; + + async function refresh(): Promise { + if (refreshing) return; + refreshing = true; + state.loading = true; + app.invalidate(); + try { + state.snapshot = await collectDashboard({ + baseUrl: opts.baseUrl, + token: opts.token, + range: state.range, + who: state.who, + financeDays: FINANCE_DAYS[state.range] ?? 30, + concurrency: opts.concurrency ?? 8, + coinpay: opts.coinpay, + only: opts.only ?? null, + }); + state.error = null; + state.lastRefresh = new Date(); + } catch (err) { + state.error = err instanceof Error ? err.message : String(err); + } finally { + state.loading = false; + refreshing = false; + app.invalidate(); + } + } + + const interval = Math.max(10, opts.interval ?? 60); + const poll = setInterval(() => { + if (!state.paused) void refresh(); + }, interval * 1000); + poll.unref?.(); + + 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.render(({ ui, theme, height }: RenderArgs) => { + ui.row({ size: 1 }, (header) => { + header.text(" CrawlProof ", { fg: theme.title, bold: true, size: 12 }); + header.tabs({ + tabs: TABS.map((name, i) => `${i + 1} ${name}`), + active: state.tab, + onSelect: (index: number) => { + state.tab = index; + }, + }); + const right = [ + state.paused ? "paused" : state.loading ? "loading…" : `${state.range} · ${state.who}`, + state.lastRefresh ? `updated ${ago(state.lastRefresh)}` : "starting", + clock(), + ].join(" "); + header.text(`${right} `, { + fg: state.paused ? theme.warning : state.error ? theme.danger : theme.success, + align: "right", + }); + }); + 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.spacer(1); + const errorCount = state.snapshot ? Object.keys(state.snapshot.errors).length : 0; + ui.statusBar({ + items: [ + { key: "1-5", label: "Screen" }, + { key: "r", label: "Refresh" }, + { key: "w", label: `Window ${state.range}` }, + { key: "b", label: state.who }, + { key: "p", label: state.paused ? "Resume" : "Pause", active: state.paused }, + { key: "?", label: "Help" }, + { key: "q", label: "Quit" }, + ], + right: errorCount + ? [{ label: `${errorCount} source${errorCount > 1 ? "s" : ""} unavailable`, color: theme.warning }] + : [{ label: "all sources live", color: theme.success }], + }); + + if (state.showHelp) { + ui.modal({ + title: "CrawlProof — Spend & ROI", + width: 70, + height: 22, + 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" + + "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" + + "Press any key to close.", + buttons: [{ label: "Close", focused: true }], + }); + } + }); + + app.on("exit", () => { + clearInterval(poll); + clearInterval(tick); + }); + + void refresh(); + await app.start(); +} diff --git a/cli/index.ts b/cli/index.ts index 105420f..17c79e6 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -13,6 +13,8 @@ // involvement — handy for local debugging. `report` fetches a public report // by share-token from the production API. +import { readFileSync } from "node:fs"; + import { isAllowedTargetUrl } from "../lib/rateLimit"; type Args = { @@ -429,6 +431,89 @@ async function cmdSlots(args: Args): Promise { return 2; } +/** + * The CoinPay merchant session the finance half of the dashboard needs. + * + * Same file `coinpay auth login` writes, because asking someone to paste a JWT + * they already have on disk is not a login flow. Absent is fine: the dashboard + * runs without it and says which panels are missing. + */ +function coinpayAuth(args: Args): { token: string; baseUrl: string } | null { + // CoinPay's SDK wants a base that includes /api, but COINPAY_API_URL is the + // site origin everywhere else in this repo (it is set that way in the + // production environment). Accept either and normalise, because the failure + // otherwise is an HTML page parsed as JSON, which names neither cause. + const configured = ( + (args.flags["coinpay-url"] as string | undefined) ?? + process.env.COINPAY_API_URL ?? + "https://coinpayportal.com/api" + ).replace(/\/$/, ""); + const baseUrl = /\/api$/.test(configured) ? configured : `${configured}/api`; + + const fromEnv = process.env.COINPAY_SESSION_TOKEN?.trim(); + if (fromEnv) return { token: fromEnv, baseUrl }; + + try { + const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; + const file = process.env.COINPAY_CONFIG ?? `${home}/.coinpay.json`; + const token = (JSON.parse(readFileSync(file, "utf8")) as { jwtToken?: string }).jwtToken; + return token ? { token: token.trim(), baseUrl } : null; + } catch { + return null; + } +} + +async function cmdDashboard(args: Args): Promise { + const token = apiToken(args); + if (!token) { + console.error("Set CRAWLPROOF_TOKEN (or --token) to a crp_… API token from Social → API tokens."); + return 2; + } + + const range = (args.flags.range as string | undefined) ?? "1d"; + const who = (args.flags.who as string | undefined) ?? "humans"; + const only = typeof args.flags.sites === "string" ? args.flags.sites.split(",").map((s) => s.trim()).filter(Boolean) : null; + const coinpay = args.flags["no-coinpay"] ? null : coinpayAuth(args); + + // --json is the same snapshot the screens render, for a script or a check + // that cannot open a terminal. + if (args.flags.json) { + const { collectDashboard } = await import("../lib/dashboard/collect"); + const { FINANCE_DAYS } = await import("./dashboard"); + const snapshot = await collectDashboard({ + baseUrl: apiBase(args), + token, + range, + who, + financeDays: FINANCE_DAYS[range] ?? 30, + concurrency: Number(args.flags.concurrency) || 8, + coinpay, + only, + }); + process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`); + return 0; + } + + if (!process.stdout.isTTY) { + console.error("The dashboard needs a terminal. Use --json for a snapshot, or `crawlproof stats` for one site."); + return 2; + } + + const { runDashboard } = await import("./dashboard"); + await runDashboard({ + baseUrl: apiBase(args), + token, + range, + who, + interval: Number(args.flags.interval) || 60, + concurrency: Number(args.flags.concurrency) || 8, + coinpay, + only, + theme: args.flags.theme as string | undefined, + }); + return 0; +} + function help() { console.log(`crawlproof — AEO audit CLI (stub) @@ -492,6 +577,14 @@ COMMANDS month of crawler traffic. The site is a hostname, a project id or a 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] + 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. + help Print this message. @@ -499,7 +592,12 @@ ENV ANTHROPIC_API_KEY Required for --engine=claude. CRAWLPROOF_SITE_URL Override the API base URL for 'report', 'sweep', 'track', 'ads' and 'slots'. CRAWLPROOF_PROJECT Default project UUID for 'track'. - CRAWLPROOF_TOKEN API token (crp_…) for 'ads' and 'slots'; --token overrides. + CRAWLPROOF_TOKEN API token (crp_…) for 'ads', 'slots', 'stats' and + 'dashboard'; --token overrides. + COINPAY_SESSION_TOKEN CoinPay merchant JWT for the money half of + 'dashboard'. Defaults to jwtToken in ~/.coinpay.json, + which 'coinpay auth login' writes. + COINPAY_API_URL CoinPay API base (default https://coinpayportal.com/api). CRON_SECRET Required for 'sweep'. EXAMPLES @@ -511,6 +609,8 @@ EXAMPLES crawlproof track --project=ac4e0a7d-... --event=signup --target=hero_cta CRAWLPROOF_TOKEN=crp_... crawlproof ads create https://nichedb.dev --name "NicheDB" CRAWLPROOF_TOKEN=crp_... crawlproof slots create nichedb.dev + CRAWLPROOF_TOKEN=crp_... crawlproof dashboard --range=1w + CRAWLPROOF_TOKEN=crp_... crawlproof dashboard --json | jq .roi.derived `); } @@ -532,6 +632,10 @@ async function main() { return await cmdSlots(args); case "stats": return await cmdStats(args); + case "dashboard": + case "roi": + case "tui": + return await cmdDashboard(args); case "help": case "--help": case "-h": diff --git a/lib/ads/earnings-data.ts b/lib/ads/earnings-data.ts index 486d337..e4c8f09 100644 --- a/lib/ads/earnings-data.ts +++ b/lib/ads/earnings-data.ts @@ -14,8 +14,12 @@ import { // Unified money model for one account — a single user is both an advertiser // (ad_campaigns.owner_id) and a publisher (ad_slots.owner_id), so the earnings -// page shows BOTH spend and earnings. Everything is RLS-scoped to the caller; -// pass a request-scoped Supabase client whose auth.uid() is the account. +// page shows BOTH spend and earnings. +// +// Every query is filtered by `owner_id` explicitly rather than leaning on RLS. +// The dashboard passes a request-scoped client where that filter is a no-op +// narrowing of what RLS already allows; /api/ads/v1/earnings passes the service +// client, which has no RLS at all, and there the filter IS the boundary. export type MoneyDailyPoint = { date: string; spentCents: number; earnedCents: number }; @@ -132,7 +136,10 @@ export async function loadEarnings( { data: ledgerData }, { data: payoutsData }, ] = await Promise.all([ - supabase.from("ad_campaigns").select("id, name, status, total_spent_cents, spend_today_cents, spend_date"), + supabase + .from("ad_campaigns") + .select("id, name, status, 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 // report zero for every campaign and every site. The RPCs take a window and @@ -140,12 +147,13 @@ export async function loadEarnings( getCampaignTotalsSince(supabase, since), // Monetization is owner-only (payouts go to the slot owner), like /ads/slots. supabase.from("projects").select("id, name").eq("owner_id", userId), - supabase.from("ad_slots").select("id, project_id, status"), + supabase.from("ad_slots").select("id, project_id, status").eq("owner_id", userId), getSlotTotalsSince(supabase, since), - supabase.from("ad_ledger").select("slot_id, amount_cents").eq("kind", "publisher_accrual"), + supabase.from("ad_ledger").select("slot_id, amount_cents").eq("kind", "publisher_accrual").eq("owner_id", userId), supabase .from("ad_payouts") .select("amount_cents, currency, status, tx_hash, created_at") + .eq("owner_id", userId) .order("created_at", { ascending: false }), ]); diff --git a/lib/dashboard/collect.ts b/lib/dashboard/collect.ts new file mode 100644 index 0000000..30bc466 --- /dev/null +++ b/lib/dashboard/collect.ts @@ -0,0 +1,251 @@ +// Gathering the three feeds the ROI dashboard joins. +// +// CrawlProof answers per project, so the fleet is a fan-out: one /stats call +// per site, concurrency-capped. That is deliberate rather than a missing +// server-side aggregate — summing 50-odd projects inside one serverless +// request is how the tracker RPCs have timed out before, and a slow client is +// a much better failure than a route that 504s for everybody. +// +// CoinPay is one call into its own SDK, which is the whole point: the finance +// dashboard already exists and this reads it rather than reimplementing it. +// +// Nothing here throws for a partial answer. A source that fails lands in +// `errors` and its panel says so, because a dashboard that hides a dead feed +// behind a zero is worse than one that says the feed is dead. + +import { + buildRoi, + type AdsInput, + type FinanceInput, + type RoiModel, + type SiteTraffic, +} from "./roi"; + +export type ListItem = { label: string; value: number }; + +export type SiteStats = SiteTraffic & { + id?: string; + url?: string; + sources: ListItem[]; + referrers: ListItem[]; + pages: ListItem[]; +}; + +export type DashboardSnapshot = { + generatedAt: string; + window: { range: string; who: string; financeDays: number }; + sites: SiteStats[]; + fleet: { sources: ListItem[]; referrers: ListItem[]; pages: ListItem[] }; + ads: AdsInput | null; + finance: FinanceInput | null; + roi: RoiModel; + /** Source name → why it is missing. Empty when everything answered. */ + errors: Record; +}; + +const TIMEOUT_MS = 20_000; + +async function fetchJson(url: string, token: string, timeoutMs = TIMEOUT_MS): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + signal: controller.signal, + }); + const text = await res.text(); + let body: unknown; + try { + body = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`${res.status} ${res.statusText}: not JSON`); + } + if (!res.ok) { + const message = (body as { error?: string })?.error ?? `${res.status} ${res.statusText}`; + throw new Error(message); + } + return body as T; + } finally { + clearTimeout(timer); + } +} + +/** Run `fn` over `items`, at most `limit` in flight. */ +export async function mapLimit( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const out = new Array(items.length); + let next = 0; + const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => { + for (;;) { + const i = next++; + if (i >= items.length) return; + out[i] = await fn(items[i] as T, i); + } + }); + await Promise.all(workers); + return out; +} + +/** Merge per-site lists into one fleet list, largest first. */ +export function mergeLists(lists: ListItem[][], limit = 12): ListItem[] { + const total = new Map(); + for (const list of lists) { + for (const item of list ?? []) { + const label = String(item?.label ?? "").trim(); + if (!label) continue; + total.set(label, (total.get(label) ?? 0) + (Number(item?.value) || 0)); + } + } + return [...total.entries()] + .map(([label, value]) => ({ label, value })) + .sort((a, b) => b.value - a.value) + .slice(0, limit); +} + +type SiteRow = { id: string; name: string; url: string; tracker_enabled?: boolean | null }; + +export async function listSites(baseUrl: string, token: string): Promise { + const body = await fetchJson<{ sites?: SiteRow[] }>(`${baseUrl}/api/tracker/v1/sites`, token); + return body.sites ?? []; +} + +async function statsForSite( + baseUrl: string, + token: string, + site: SiteRow, + range: string, + who: string, +): Promise { + const url = `${baseUrl}/api/tracker/v1/stats?site=${encodeURIComponent(site.id)}&range=${encodeURIComponent(range)}&who=${encodeURIComponent(who)}`; + try { + const body = await fetchJson<{ + totals?: { visitors?: number; pageviews?: number }; + sources?: ListItem[]; + referrers?: ListItem[]; + pages?: ListItem[]; + }>(url, token); + return { + site: site.name, + id: site.id, + url: site.url, + visitors: Number(body.totals?.visitors) || 0, + pageviews: Number(body.totals?.pageviews) || 0, + sources: body.sources ?? [], + referrers: body.referrers ?? [], + pages: body.pages ?? [], + }; + } catch (err) { + return { + site: site.name, + id: site.id, + url: site.url, + visitors: 0, + pageviews: 0, + sources: [], + referrers: [], + pages: [], + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export type CoinPayAuth = { token: string; baseUrl: string }; + +/** + * The CoinPay finance snapshot, via CoinPay's own SDK. + * + * Imported lazily so that a box without the package — or without a merchant + * session — still gets a traffic and ads dashboard instead of a stack trace. + */ +export async function collectFinance( + auth: CoinPayAuth, + days: number, +): Promise { + const [{ default: CoinPayClient }, finances] = await Promise.all([ + import("@profullstack/coinpay"), + import("@profullstack/coinpay/finances"), + ]); + const client = new CoinPayClient({ apiKey: auth.token, baseUrl: auth.baseUrl }); + // 500 rather than the default page: the vendor breakdown is only honest if + // the ledger it groups covers the whole window. + return (await finances.collectFinanceSnapshot(client, { days, limit: 500 })) as FinanceInput; +} + +export type CollectOptions = { + baseUrl: string; + token: string; + range: string; + who: string; + financeDays: number; + concurrency?: number; + coinpay: CoinPayAuth | null; + /** Limit the fan-out to these site names or ids. */ + only?: string[] | null; +}; + +export async function collectDashboard(opts: CollectOptions): Promise { + const errors: Record = {}; + + const sitesPromise = listSites(opts.baseUrl, opts.token).catch((err: unknown) => { + errors.sites = err instanceof Error ? err.message : String(err); + return [] as SiteRow[]; + }); + + const adsPromise = fetchJson( + `${opts.baseUrl}/api/ads/v1/earnings?days=${encodeURIComponent(String(opts.financeDays))}`, + opts.token, + ).catch((err: unknown) => { + errors.ads = err instanceof Error ? err.message : String(err); + return null; + }); + + const financePromise = opts.coinpay + ? collectFinance(opts.coinpay, opts.financeDays).catch((err: unknown) => { + errors.finance = err instanceof Error ? err.message : String(err); + return null; + }) + : Promise.resolve(null); + if (!opts.coinpay) { + errors.finance = "No CoinPay session. Run `coinpay auth login`, or set COINPAY_SESSION_TOKEN."; + } + + let siteRows = await sitesPromise; + if (opts.only?.length) { + const wanted = new Set(opts.only.map((s) => s.toLowerCase())); + siteRows = siteRows.filter((s) => wanted.has(s.name.toLowerCase()) || wanted.has(s.id)); + } + + const sites = await mapLimit(siteRows, opts.concurrency ?? 8, (site) => + statsForSite(opts.baseUrl, opts.token, site, opts.range, opts.who), + ); + sites.sort((a, b) => b.visitors - a.visitors || a.site.localeCompare(b.site)); + + const failed = sites.filter((s) => s.error).length; + if (failed) errors.stats = `${failed} of ${sites.length} sites did not answer`; + + const [ads, finance] = await Promise.all([adsPromise, financePromise]); + + const roi = buildRoi({ + traffic: { range: opts.range, who: opts.who, sites }, + ads, + finance, + }); + + return { + generatedAt: new Date().toISOString(), + window: { range: opts.range, who: opts.who, financeDays: opts.financeDays }, + sites, + fleet: { + sources: mergeLists(sites.map((s) => s.sources)), + referrers: mergeLists(sites.map((s) => s.referrers)), + pages: mergeLists(sites.map((s) => s.pages)), + }, + ads, + finance, + roi, + errors, + }; +} diff --git a/lib/dashboard/roi.ts b/lib/dashboard/roi.ts new file mode 100644 index 0000000..6d3e5dd --- /dev/null +++ b/lib/dashboard/roi.ts @@ -0,0 +1,391 @@ +// What the fleet costs, what it returns, and the ratio between them. +// +// Three sources answer three different questions and none of them answers this +// one alone: CrawlProof knows who showed up, the ad network knows what was +// delivered, and CoinPay knows what the bank actually did. This module is the +// arithmetic that joins them, kept pure so it can be tested against real +// shapes without a network. +// +// Two rules run through all of it, and both exist because breaking either one +// produces a flattering number that is false: +// +// 1. **Self-deal is not revenue.** The ad network runs with one account on +// both sides — we advertise on our own slots — so `spentCents` and +// `earnedCents` are the same dollar leaving one pocket and arriving in the +// other. They are reported under `internal`, never added to revenue, and +// `internal.net` should stay near zero. If it ever does not, that is a +// bug in the ledger rather than a profit. +// 2. **Personal money is not fleet cost.** The bank feed carries one human's +// groceries next to the servers. Only the `business` scope is spend. +// +// Everything is normalised to a **monthly rate** and then prorated onto the +// traffic window. Burn is a rate, not a balance, and a rate survives the fact +// that the traffic side can be asked for an hour while the bank side only +// answers in weeks. + +/** Days covered by each tracker range, for prorating a monthly rate onto it. */ +export const RANGE_DAYS: Record = { + "1h": 1 / 24, + "4h": 1 / 6, + "1d": 1, + "1w": 7, + "1m": 30, +}; + +export function rangeDays(range: string): number { + return RANGE_DAYS[range] ?? 1; +} + +const n = (v: unknown): number => { + const x = Number(v); + return Number.isFinite(x) ? x : 0; +}; +const cents = (v: unknown): number => n(v) / 100; + +/** One site's slice of the fleet's traffic. */ +export type SiteTraffic = { + site: string; + visitors: number; + pageviews: number; + /** Set when this site's stats call failed; its numbers are zero, not observed. */ + error?: string; +}; + +export type TrafficInput = { + range: string; + who: string; + sites: SiteTraffic[]; +}; + +/** The subset of /api/ads/v1/earnings this module reads. */ +export type AdsInput = { + rangeDays?: number; + statsUnavailable?: boolean; + totals?: { + spentCents?: number; + earnedCents?: number; + availableCents?: number; + advImpressions?: number; + advClicks?: number; + pubImpressions?: number; + pubClicks?: number; + invalidClicks?: number; + }; +}; + +/** The subset of the CoinPay finance snapshot this module reads. */ +export type FinanceInput = { + windowDays?: number; + earnings?: { commissionUsd?: number; grossVolumeUsd?: number; netUsd?: number }; + position?: { + lookbackDays?: number; + monthsObserved?: number; + spending?: { perMonth?: number }; + income?: { perMonth?: number }; + ratios?: { monthsOfCover?: number | null }; + scopes?: Array<{ + scope?: string; + spending?: number; + income?: number; + accounts?: number; + }>; + }; + bank?: { + /** + * A ledger row names an account, not a scope — `effective_scope` lives on + * the account. Joining the two is what keeps groceries out of the fleet's + * bill, so both halves are required. + */ + accounts?: Array<{ id?: string; effective_scope?: string | null; name?: string | null }>; + ledger?: Array<{ + account_id?: string | null; + payee?: string | null; + description?: string | null; + amount?: number | null; + category?: string | null; + posted?: string | null; + }>; + /** Rows in the window; `ledger` may be one page of it. */ + ledgerTotal?: number; + }; +}; + +export type VendorSpend = { payee: string; usd: number; charges: number }; + +export type RoiModel = { + window: { range: string; days: number; who: string }; + cost: { + /** Business-scope burn, the rate everything else is prorated from. */ + perMonthUsd: number; + /** That rate over the traffic window. */ + windowUsd: number; + /** True when the bank feed never labelled an account business. */ + scopeMissing: boolean; + /** Whole-feed burn including personal, for context only. */ + allScopesPerMonthUsd: number; + vendors: VendorSpend[]; + /** True when `vendors` was built from one page of a longer ledger. */ + vendorsPartial: boolean; + }; + revenue: { + /** Money from outside the fleet: the only kind that counts. */ + perMonthUsd: number; + windowUsd: number; + commissionPerMonthUsd: number; + grossVolumePerMonthUsd: number; + }; + /** Money moving between our own products. Never revenue; see rule 1. */ + internal: { + adSpendUsd: number; + adEarnedUsd: number; + netUsd: number; + availableUsd: number; + }; + attention: { + visitors: number; + pageviews: number; + sites: number; + sitesReporting: number; + impressions: number; + clicks: number; + ctr: number | null; + }; + derived: { + netPerMonthUsd: number; + /** (revenue − cost) / cost. Null when there is no cost to divide by. */ + roi: number | null; + costPerVisitorUsd: number | null; + /** + * The sturdier denominator. A "visitor" here is any visit the tracker did + * not classify as a crawler, which on a site with a machine-readable + * endpoint runs orders of magnitude above the pages anyone actually read. + */ + costPerPageviewUsd: number | null; + revenuePerVisitorUsd: number | null; + /** Visitors per month needed to cover burn at the current revenue/visitor. */ + breakEvenVisitors: number | null; + monthsOfCover: number | null; + }; + /** Why a number is missing or should not be read straight. */ + caveats: string[]; +}; + +/** + * Sum a fleet's worth of per-site stats. + * + * Sites whose stats call failed are counted in `sites` but not in + * `sitesReporting`, so a partial fan-out cannot quietly read as a quiet day. + */ +export function sumTraffic(sites: SiteTraffic[]): { + visitors: number; + pageviews: number; + sites: number; + sitesReporting: number; +} { + let visitors = 0; + let pageviews = 0; + let reporting = 0; + for (const s of sites) { + if (s.error) continue; + reporting += 1; + visitors += n(s.visitors); + pageviews += n(s.pageviews); + } + return { visitors, pageviews, sites: sites.length, sitesReporting: reporting }; +} + +/** + * Business-scope spend as a monthly rate. + * + * Prefers the per-scope split; falls back to the whole-feed rate when no + * account has been marked business, and says so through `scopeMissing` rather + * than silently billing the fleet for someone's groceries. + */ +export function businessBurn(finance: FinanceInput): { + perMonthUsd: number; + allScopesPerMonthUsd: number; + scopeMissing: boolean; +} { + const position = finance.position ?? {}; + const allScopes = n(position.spending?.perMonth); + const months = n(position.monthsObserved); + const scopes = position.scopes ?? []; + const business = scopes.find((s) => s?.scope === "business"); + + // `scopes[].spending` is a total over the lookback, not a rate; the observed + // month count is what turns it into one. + if (business && months > 0) { + return { + perMonthUsd: n(business.spending) / months, + allScopesPerMonthUsd: allScopes, + scopeMissing: false, + }; + } + return { perMonthUsd: allScopes, allScopesPerMonthUsd: allScopes, scopeMissing: true }; +} + +/** Ids of the accounts the feed considers business. */ +export function businessAccountIds(finance: FinanceInput): Set { + const ids = new Set(); + for (const a of finance.bank?.accounts ?? []) { + if (a?.id && a.effective_scope === "business") ids.add(a.id); + } + return ids; +} + +/** + * Who we actually pay, largest first. + * + * Debits only, business accounts only, and grouped by payee so twelve + * Anthropic charges read as one line with a number worth acting on. With no + * business account marked the whole feed is used, which is wrong but visibly + * wrong — `buildRoi` raises the same caveat for the burn rate. + */ +export function vendorSpend(finance: FinanceInput, limit = 12): VendorSpend[] { + const rows = finance.bank?.ledger ?? []; + const business = businessAccountIds(finance); + const byPayee = new Map(); + for (const row of rows) { + const amount = n(row?.amount); + if (amount >= 0) continue; // credits and refunds are not spend + if (business.size > 0 && !business.has(String(row?.account_id ?? ""))) continue; + const payee = (row?.payee || row?.description || "Unknown").trim() || "Unknown"; + const prev = byPayee.get(payee) ?? { payee, usd: 0, charges: 0 }; + prev.usd += Math.abs(amount); + prev.charges += 1; + byPayee.set(payee, prev); + } + return [...byPayee.values()].sort((a, b) => b.usd - a.usd).slice(0, limit); +} + +/** Turn a figure covering `days` into a monthly rate. */ +export function toMonthly(total: number, days: number): number { + if (!(days > 0)) return 0; + return (n(total) * 30) / days; +} + +export function buildRoi(input: { + traffic: TrafficInput; + ads: AdsInput | null; + finance: FinanceInput | null; +}): RoiModel { + const { traffic } = input; + const ads = input.ads ?? {}; + const finance = input.finance ?? {}; + const days = rangeDays(traffic.range); + const caveats: string[] = []; + + const attention = sumTraffic(traffic.sites ?? []); + const adTotals = ads.totals ?? {}; + const impressions = n(adTotals.pubImpressions); + const clicks = n(adTotals.pubClicks); + + const burn = businessBurn(finance); + const costWindow = (burn.perMonthUsd * days) / 30; + + // Commission is our cut of merchant volume and the only line here that is + // money from outside the fleet. + const financeDays = n(finance.windowDays) || 30; + const commissionPerMonth = toMonthly(n(finance.earnings?.commissionUsd), financeDays); + const grossPerMonth = toMonthly(n(finance.earnings?.grossVolumeUsd), financeDays); + const revenuePerMonth = commissionPerMonth; + const revenueWindow = (revenuePerMonth * days) / 30; + + const adSpendUsd = cents(adTotals.spentCents); + const adEarnedUsd = cents(adTotals.earnedCents); + + const netPerMonth = revenuePerMonth - burn.perMonthUsd; + const roi = burn.perMonthUsd > 0 ? (revenuePerMonth - burn.perMonthUsd) / burn.perMonthUsd : null; + + const costPerVisitor = attention.visitors > 0 ? costWindow / attention.visitors : null; + const costPerPageview = attention.pageviews > 0 ? costWindow / attention.pageviews : null; + const revenuePerVisitor = attention.visitors > 0 ? revenueWindow / attention.visitors : null; + const breakEvenVisitors = + revenuePerVisitor && revenuePerVisitor > 0 ? burn.perMonthUsd / revenuePerVisitor : null; + + if (burn.scopeMissing) { + caveats.push( + "No account is marked business, so spend is the whole bank feed — personal included.", + ); + } + if (attention.sitesReporting < attention.sites) { + caveats.push( + `${attention.sites - attention.sitesReporting} of ${attention.sites} sites did not answer; their traffic is missing, not zero.`, + ); + } + if (ads.statsUnavailable) { + caveats.push("An ad delivery query failed; impressions and clicks are zero-filled."); + } + // A visit is any non-crawler hit, a pageview is a rendered page. When the + // first dwarfs the second the fleet is being measured by something that + // never read anything, and per-visitor money is the wrong number to quote. + if (attention.pageviews > 0 && attention.visitors > attention.pageviews * 5) { + caveats.push( + `${Math.round(attention.visitors / attention.pageviews)}× more visits than pageviews — most arrivals never rendered a page. Prefer the per-pageview figure.`, + ); + } + const busiest = [...(traffic.sites ?? [])] + .filter((s) => !s.error) + .sort((a, b) => n(b.visitors) - n(a.visitors))[0]; + if (busiest && attention.visitors > 0 && n(busiest.visitors) > attention.visitors * 0.5) { + caveats.push( + `${busiest.site} is ${Math.round((n(busiest.visitors) / attention.visitors) * 100)}% of fleet visits, so a fleet average mostly describes that one site.`, + ); + } + if (adSpendUsd > 0 || adEarnedUsd > 0) { + caveats.push( + "Ad spend and ad earnings are the same account on both sides of the network, so neither is counted as cost or revenue.", + ); + } + if (financeDays !== 30) { + caveats.push(`CoinPay figures cover ${financeDays}d, rescaled to a monthly rate.`); + } + const ledgerRows = finance.bank?.ledger?.length ?? 0; + const ledgerTotal = n(finance.bank?.ledgerTotal); + const vendorsPartial = ledgerTotal > ledgerRows; + if (vendorsPartial) { + caveats.push( + `Vendors cover the newest ${ledgerRows} of ${ledgerTotal} transactions; the burn rate above does not.`, + ); + } + + return { + window: { range: traffic.range, days, who: traffic.who }, + cost: { + perMonthUsd: burn.perMonthUsd, + windowUsd: costWindow, + scopeMissing: burn.scopeMissing, + allScopesPerMonthUsd: burn.allScopesPerMonthUsd, + vendors: vendorSpend(finance), + vendorsPartial, + }, + revenue: { + perMonthUsd: revenuePerMonth, + windowUsd: revenueWindow, + commissionPerMonthUsd: commissionPerMonth, + grossVolumePerMonthUsd: grossPerMonth, + }, + internal: { + adSpendUsd, + adEarnedUsd, + netUsd: adEarnedUsd - adSpendUsd, + availableUsd: cents(adTotals.availableCents), + }, + attention: { + ...attention, + impressions, + clicks, + ctr: impressions > 0 ? clicks / impressions : null, + }, + derived: { + netPerMonthUsd: netPerMonth, + roi, + costPerVisitorUsd: costPerVisitor, + costPerPageviewUsd: costPerPageview, + revenuePerVisitorUsd: revenuePerVisitor, + breakEvenVisitors, + monthsOfCover: finance.position?.ratios?.monthsOfCover ?? null, + }, + caveats, + }; +} diff --git a/lib/tracker/apiStats.ts b/lib/tracker/apiStats.ts index 0fb6c07..f1f67d1 100644 --- a/lib/tracker/apiStats.ts +++ b/lib/tracker/apiStats.ts @@ -39,15 +39,24 @@ export type ResolveResult = * matches it, so `crawlproof stats nichedb.dev` and `crawlproof slots create * nichedb.dev` mean the same site. */ -export async function resolveProject(sb: Sb, userId: string, site: string | null): Promise { +export async function listProjects( + sb: Sb, + userId: string, +): Promise<{ ok: true; projects: ProjectRow[] } | { ok: false; status: number; error: string }> { const { data, error } = await sb .from("projects") .select("id, name, url, tracker_enabled") .eq("owner_id", userId) .is("archived_at", null); if (error) return { ok: false, status: 500, error: error.message }; + return { ok: true, projects: (data ?? []) as ProjectRow[] }; +} + +export async function resolveProject(sb: Sb, userId: string, site: string | null): Promise { + const listed = await listProjects(sb, userId); + if (!listed.ok) return listed; - const projects = (data ?? []) as ProjectRow[]; + const projects = listed.projects; if (!projects.length) return { ok: false, status: 404, error: "No projects on this account yet." }; if (!site) { diff --git a/package-lock.json b/package-lock.json index 0fe2ab9..3949c2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@libsql/client": "^0.17.3", "@modelcontextprotocol/sdk": "^1.26.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", + "@profullstack/coinpay": "^0.9.0", + "@profullstack/hqtui": "^0.1.11", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", "@profullstack/x402-client": "^0.2.0", @@ -1056,6 +1058,350 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", + "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.4.tgz", + "integrity": "sha512-oKT5RNeO9oKYizSyOxKb66IrKUsYVMQD2tnjllYW4wmSOe9WawfL3OE2p82JuvAyo+/nDIEem5P4Bm/ZIVYVew==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.2", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.1.tgz", + "integrity": "sha512-HvnOHal39DTenOVoRpIy8Z+n4YYfNa3Qhi/P8zFqn9/d1crpmQG0DPx34rwOeOFvotgKr9Vov/14OmKR/Iwfjw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.2.tgz", + "integrity": "sha512-9pBhkxE14uUlnHhs8lOt7qVPtS4caRY6CjADl8COejl9Mf7w8i6Uoe3DrljCqYtmYM3Iv2Q6EmPxx/oTYbvTAQ==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.2.tgz", + "integrity": "sha512-7ruKO5d/wxhGHxdCQ3eyP/aBNrPTZ+Ju7ERtnAwuIsu0RSLs6kotlTRkOxHatbD6sDaEEdwsD82pzFiT9ESIyQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/external-editor": "^3.0.5", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.4.tgz", + "integrity": "sha512-12o4aYnjWouoq0fyVhYIL+hwXAaga+kntA/wrijFmVvn2heinkdvkbEXRt1Ob/9IOJ285US5tntjfI/Ukt2YkA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.5.tgz", + "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.5.tgz", + "integrity": "sha512-2MkYYFD1Owt1eRBwHhsLSutysRodWasA4DVpl42KJKGmzwBTXRYAIXujisrt0UW0UohUlMjNjLfMDbw4emRbfw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.2.tgz", + "integrity": "sha512-tjW1gMIQsFb/9Ie11i8SpuPARBdyCVfC5XOeVugWovrdm8CpjzN+A1CBX5QwCCN2mtNmpgDDCnuLCCT94cQ5zA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.1.tgz", + "integrity": "sha512-InA6nJxMCXPavPBiTKYHIsYiWz/mkIsk1rkI6pKp5w18gVawqrZOj7eSzSmPJa3j0pVxXm6RlrFP1BE9YeOixA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.1.tgz", + "integrity": "sha512-xJIKWyrFNUKj3R5VEub/iKodG9Sh/MbCeApZUbATJgHTw8YoAJ2gOT78HlvO6NMFiRvjm6ySM7aiAew/zVY0Sw==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.4", + "@inquirer/confirm": "^6.3.1", + "@inquirer/editor": "^5.3.2", + "@inquirer/expand": "^5.1.4", + "@inquirer/input": "^5.1.5", + "@inquirer/number": "^4.2.2", + "@inquirer/password": "^5.2.1", + "@inquirer/rawlist": "^5.3.4", + "@inquirer/search": "^4.3.2", + "@inquirer/select": "^5.2.4" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.4.tgz", + "integrity": "sha512-c4fDwOpQsjJDHjXdHJE1/xXH6w9/BAiyu3QKzH9Ww5Xm1q37mzyUNa20L7qdtM1ECi4e+oUEKjhK8hR37OMAMQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.2.tgz", + "integrity": "sha512-YIaEhWmfkdHEWOKwhF/oJar35FFTTrAdzNbJDo9lSsRT6E/obRPx9oVm4rkTMtlcJSqGYttNtLbcHQef9Qw2yw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.2", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.4.tgz", + "integrity": "sha512-L9ubNaJdoBsiom/AfPKq0QLHTkhhCFCtszzdVidGremrlJjPZfGlmvp+IULKIbnYjtVFWnvb4mhiEdNTZ12ugA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.2", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.1.tgz", + "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@ioredis/commands": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", @@ -1663,12 +2009,72 @@ "node": ">=18" } }, + "node_modules/@profullstack/coinpay": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@profullstack/coinpay/-/coinpay-0.9.0.tgz", + "integrity": "sha512-+D0peeeue615Rl89LhYWloB6ln6JAJPuqq4VX0fSPiHq9DHL9Y+YsCXsYM9+QuVHbt8uIyv9R+jA8VrKoswMgA==", + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^8.3.0", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@profullstack/hqtui": "^0.1.11", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0" + }, + "bin": { + "coinpay": "bin/coinpay.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@profullstack/coinpay/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@profullstack/coinpay/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@profullstack/emailer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@profullstack/emailer/-/emailer-1.0.1.tgz", "integrity": "sha512-/uhHJJGH+1xSSz3mJn6X+m6aruYjMD3JOaRp/d4R/YWlzpy07H9z0/JUleIyRyBPNmaANSIwjTZ7aVjaukOEpg==", "license": "MIT" }, + "node_modules/@profullstack/hqtui": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@profullstack/hqtui/-/hqtui-0.1.11.tgz", + "integrity": "sha512-mDJBScaU+/81zpAG+hmjEvOKeLmCvx3AZ9DOTbJcVCKJdlVh77h9OAHa6UfzJPHbIGt7Xh0VwWZ9zIywyS3lgA==", + "license": "MIT", + "bin": { + "hqtui": "bin/hqtui.mjs" + }, + "engines": { + "bun": ">=1.1", + "node": ">=22.6" + } + }, "node_modules/@profullstack/referrals": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@profullstack/referrals/-/referrals-0.1.0.tgz", @@ -2058,6 +2464,81 @@ "dev": true, "license": "MIT" }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -3123,6 +3604,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "license": "MIT" + }, "node_modules/cheerio": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", @@ -3165,6 +3652,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4001,6 +4497,21 @@ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "license": "Unlicense" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -4017,6 +4528,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fast-xml-builder": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", @@ -5393,6 +5913,15 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -6597,6 +7126,18 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simplesignal": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/simplesignal/-/simplesignal-2.1.7.tgz", diff --git a/package.json b/package.json index e577761..28b2764 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "@libsql/client": "^0.17.3", "@modelcontextprotocol/sdk": "^1.26.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", + "@profullstack/coinpay": "^0.9.0", + "@profullstack/hqtui": "^0.1.11", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", "@profullstack/x402-client": "^0.2.0", diff --git a/tests/dashboard-collect.test.ts b/tests/dashboard-collect.test.ts new file mode 100644 index 0000000..ee3eb8a --- /dev/null +++ b/tests/dashboard-collect.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { mapLimit, mergeLists } from "@/lib/dashboard/collect"; +import { parseDays } from "@/app/api/ads/v1/earnings/route"; + +describe("mapLimit", () => { + it("keeps results in input order however they finish", async () => { + const out = await mapLimit([30, 10, 20, 0], 2, async (ms) => { + await new Promise((r) => setTimeout(r, ms)); + return ms; + }); + expect(out).toEqual([30, 10, 20, 0]); + }); + + it("never runs more than the limit at once", async () => { + let inFlight = 0; + let peak = 0; + await mapLimit(Array.from({ length: 20 }, (_, i) => i), 4, async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight -= 1; + }); + expect(peak).toBeLessThanOrEqual(4); + }); + + it("handles an empty list without hanging", async () => { + expect(await mapLimit([], 8, async () => 1)).toEqual([]); + }); +}); + +describe("mergeLists", () => { + it("sums a label across sites and orders by size", () => { + const merged = mergeLists([ + [ + { label: "Search · google", value: 10 }, + { label: "Social · reddit", value: 3 }, + ], + [{ label: "Search · google", value: 5 }], + ]); + expect(merged[0]).toEqual({ label: "Search · google", value: 15 }); + expect(merged[1]).toEqual({ label: "Social · reddit", value: 3 }); + }); + + it("drops blank labels and survives a missing list", () => { + const merged = mergeLists([ + [{ label: "", value: 9 }], + undefined as unknown as { label: string; value: number }[], + [{ label: "ok", value: 1 }], + ]); + expect(merged).toEqual([{ label: "ok", value: 1 }]); + }); + + it("caps the list", () => { + const many = Array.from({ length: 40 }, (_, i) => ({ label: `s${i}`, value: i })); + expect(mergeLists([many], 5)).toHaveLength(5); + }); +}); + +describe("earnings route window", () => { + it("accepts the windows the dashboard offers", () => { + expect(parseDays("7")).toBe(7); + expect(parseDays("365")).toBe(365); + }); + + it("falls back to 30 rather than passing anything else to the query", () => { + expect(parseDays(null)).toBe(30); + expect(parseDays("31")).toBe(30); + expect(parseDays("; drop table")).toBe(30); + }); +}); diff --git a/tests/dashboard-roi.test.ts b/tests/dashboard-roi.test.ts new file mode 100644 index 0000000..f05b0e7 --- /dev/null +++ b/tests/dashboard-roi.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; + +import { + businessAccountIds, + businessBurn, + buildRoi, + rangeDays, + sumTraffic, + toMonthly, + vendorSpend, + type FinanceInput, + type SiteTraffic, +} from "@/lib/dashboard/roi"; + +// Shapes copied from live responses, numbers invented: the real ones are one +// person's bank feed and do not belong in a repository. + +const finance = (): FinanceInput => ({ + windowDays: 30, + earnings: { commissionUsd: 120, grossVolumeUsd: 12_000, netUsd: 11_880 }, + position: { + lookbackDays: 180, + monthsObserved: 4, + spending: { perMonth: 5000 }, + income: { perMonth: 1000 }, + ratios: { monthsOfCover: 1.5 }, + scopes: [ + { scope: "personal", accounts: 3, spending: 12_000, income: 3_000 }, + { scope: "business", accounts: 2, spending: 8_000, income: 400 }, + ], + }, + bank: { + accounts: [ + { id: "biz-1", effective_scope: "business", name: "Business Card" }, + { id: "me-1", effective_scope: "personal", name: "Personal Card" }, + ], + ledgerTotal: 4, + ledger: [ + { account_id: "biz-1", payee: "Anthropic", amount: -60, category: "software" }, + { account_id: "biz-1", payee: "Anthropic", amount: -40, category: "software" }, + { account_id: "biz-1", payee: "Railway", amount: -25, category: "software" }, + { account_id: "me-1", payee: "Groceries", amount: -300, category: "food" }, + ], + }, +}); + +const ads = () => ({ + rangeDays: 30, + statsUnavailable: false, + totals: { + spentCents: 5_000, + earnedCents: 5_000, + availableCents: 1_200, + advImpressions: 900, + advClicks: 9, + pubImpressions: 1_000, + pubClicks: 20, + invalidClicks: 3, + }, +}); + +const traffic = (sites: SiteTraffic[] = [{ site: "a.com", visitors: 600, pageviews: 900 }]) => ({ + range: "1m", + who: "humans", + sites, +}); + +describe("window arithmetic", () => { + it("maps every tracker range onto days, including the sub-day ones", () => { + expect(rangeDays("1d")).toBe(1); + expect(rangeDays("1w")).toBe(7); + expect(rangeDays("1m")).toBe(30); + expect(rangeDays("4h")).toBeCloseTo(1 / 6); + expect(rangeDays("nonsense")).toBe(1); + }); + + it("rescales a window total to a monthly rate", () => { + expect(toMonthly(70, 7)).toBe(300); + expect(toMonthly(100, 30)).toBe(100); + expect(toMonthly(100, 0)).toBe(0); + }); +}); + +describe("traffic", () => { + it("counts a failed site as not reporting rather than as zero", () => { + const summed = sumTraffic([ + { site: "a", visitors: 10, pageviews: 20 }, + { site: "b", visitors: 0, pageviews: 0, error: "timeout" }, + ]); + expect(summed.visitors).toBe(10); + expect(summed.sites).toBe(2); + expect(summed.sitesReporting).toBe(1); + }); +}); + +describe("business scope", () => { + it("reads business burn as a rate from the scope total and months observed", () => { + // 8,000 over 4 observed months. + expect(businessBurn(finance()).perMonthUsd).toBe(2_000); + expect(businessBurn(finance()).scopeMissing).toBe(false); + }); + + it("falls back to the whole feed, and says so, when nothing is marked business", () => { + const f = finance(); + f.position!.scopes = [{ scope: "personal", spending: 12_000 }]; + const burn = businessBurn(f); + expect(burn.perMonthUsd).toBe(5_000); + expect(burn.scopeMissing).toBe(true); + }); + + it("picks out the business account ids", () => { + expect([...businessAccountIds(finance())]).toEqual(["biz-1"]); + }); +}); + +describe("vendors", () => { + it("groups debits by payee and leaves personal accounts out", () => { + const vendors = vendorSpend(finance()); + expect(vendors[0]).toEqual({ payee: "Anthropic", usd: 100, charges: 2 }); + expect(vendors[1]).toEqual({ payee: "Railway", usd: 25, charges: 1 }); + expect(vendors.some((v) => v.payee === "Groceries")).toBe(false); + }); + + it("ignores credits, which are not spend", () => { + const f = finance(); + f.bank!.ledger!.push({ account_id: "biz-1", payee: "Refund", amount: 50 }); + expect(vendorSpend(f).some((v) => v.payee === "Refund")).toBe(false); + }); + + it("uses the whole feed when no account is marked business", () => { + const f = finance(); + f.bank!.accounts = []; + expect(vendorSpend(f).some((v) => v.payee === "Groceries")).toBe(true); + }); +}); + +describe("buildRoi", () => { + it("never counts self-deal ad money as revenue or as cost", () => { + const model = buildRoi({ traffic: traffic(), ads: ads(), finance: finance() }); + // Commission is the only revenue; the $50 of ad earnings is not in it. + expect(model.revenue.perMonthUsd).toBe(120); + expect(model.internal.adEarnedUsd).toBe(50); + expect(model.internal.adSpendUsd).toBe(50); + expect(model.internal.netUsd).toBe(0); + // Cost is the business burn alone, with no ad spend added on top. + expect(model.cost.perMonthUsd).toBe(2_000); + expect(model.caveats.some((c) => c.includes("both sides of the network"))).toBe(true); + }); + + it("computes the ratios a spend decision actually needs", () => { + const model = buildRoi({ traffic: traffic(), ads: ads(), finance: finance() }); + expect(model.derived.roi).toBeCloseTo((120 - 2000) / 2000); + expect(model.derived.netPerMonthUsd).toBe(-1_880); + // 600 visitors in a 30d window against a $2,000/mo burn. + expect(model.derived.costPerVisitorUsd).toBeCloseTo(2000 / 600); + expect(model.derived.revenuePerVisitorUsd).toBeCloseTo(120 / 600); + expect(model.derived.breakEvenVisitors).toBeCloseTo(2000 / (120 / 600)); + expect(model.attention.ctr).toBeCloseTo(20 / 1000); + }); + + it("prorates the monthly rate onto a short window rather than comparing a month of cost to an hour of traffic", () => { + const model = buildRoi({ + traffic: traffic([{ site: "a.com", visitors: 10, pageviews: 12 }]), + ads: ads(), + finance: { ...finance(), windowDays: 30 }, + }); + expect(model.window.days).toBe(30); + + const hour = buildRoi({ + traffic: { range: "1h", who: "humans", sites: [{ site: "a.com", visitors: 10, pageviews: 12 }] }, + ads: ads(), + finance: finance(), + }); + expect(hour.cost.windowUsd).toBeCloseTo(2000 / 30 / 24); + expect(hour.cost.perMonthUsd).toBe(2_000); + }); + + it("returns null ratios instead of dividing by nothing", () => { + const model = buildRoi({ + traffic: { range: "1d", who: "humans", sites: [] }, + ads: null, + finance: null, + }); + expect(model.derived.roi).toBeNull(); + expect(model.derived.costPerVisitorUsd).toBeNull(); + expect(model.derived.breakEvenVisitors).toBeNull(); + expect(model.cost.perMonthUsd).toBe(0); + }); + + it("says when the vendor list is one page of a longer ledger", () => { + const f = finance(); + f.bank!.ledgerTotal = 400; + const model = buildRoi({ traffic: traffic(), ads: ads(), finance: f }); + expect(model.cost.vendorsPartial).toBe(true); + expect(model.caveats.some((c) => c.includes("newest 4 of 400"))).toBe(true); + }); + + it("flags missing sites so a partial fan-out cannot read as a quiet day", () => { + const model = buildRoi({ + traffic: traffic([ + { site: "a.com", visitors: 600, pageviews: 900 }, + { site: "b.com", visitors: 0, pageviews: 0, error: "500" }, + ]), + ads: ads(), + finance: finance(), + }); + expect(model.attention.sitesReporting).toBe(1); + expect(model.caveats.some((c) => c.includes("did not answer"))).toBe(true); + }); +});