diff --git a/marketing/README.md b/marketing/README.md index c2c5dbb..fd8c06c 100644 --- a/marketing/README.md +++ b/marketing/README.md @@ -22,3 +22,12 @@ a human reviews and moves it to `approved/` → the human publishes from `approv under their own account → the artifact (or a pointer) lands in `posted/`. Nothing in `drafts/` or `pending/` is ever publishable, by anyone, by any means. + +## The weekly read + +`bun --env-file=.env scripts/weekly-metrics.ts` (or `bun run metrics:weekly`) +rewrites `metrics/weekly.md` from the live databases: the staged funnel, the +campaign breakdown (`getAnalyticsSummary.topCampaigns`' grouping), and the one +stage that moved. It needs `TURSO_DB_URL`, `TURSO_DB_TOKEN`, `NOOK_DB_URL` and +`NOOK_DB_TOKEN` — no other production secret. The file is generated; never +hand-edit it. The numbers are engineering's; the interpretation is the CMO's. diff --git a/marketing/metrics/weekly.md b/marketing/metrics/weekly.md index 766ca5c..2ea9eaf 100644 --- a/marketing/metrics/weekly.md +++ b/marketing/metrics/weekly.md @@ -1,4 +1,51 @@ # Weekly funnel snapshot -Generated weekly from the analytics DB + `trials`/`licenses`/`activations`. -Format: the funnel numbers, what moved, and the one thing worth acting on. +Window: **2026-10-22 16:00:00 → 2026-10-29 16:00:00 UTC** (7 days). Previous: 2026-10-15 16:00:00 → 2026-10-22 16:00:00 UTC. + +Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated weekly, never hand-edited. The analysis is the CMO's. + +## Funnel + +| Stage | This week | Last week | Change | +|---|---:|---:|---:| +| visit (page views) | 61 | 56 | +5 | +| download click → DMG served (one label) † | 156 | 0 | +156 | +| trial started | 2 | 0 | +2 | +| checkout page visit † | 0 | 0 | 0 | +| purchase (licence issued) | 0 | 0 | 0 | +| activation | 0 | 0 | 0 | + +## Campaigns + +Top 10 by volume, untagged excluded — the same grouping `getAnalyticsSummary.topCampaigns` returns. + +| Source | Medium | Campaign | Rows | +|---|---|---|---:| +| hn | post | launch | 2 | +| direct | — | deploycheck-direct | 1 | +| hn | — | flushproof | 1 | +| hn | post | deploycheck-ref | 1 | +| r_macapps | — | week2 | 1 | +| x | — | — | 1 | + +7 of 356 rows in the window carry campaign tags; the rest are untagged and therefore unattributed. + +## The one thing that moved + +**download click → DMG served (one label): 0 → 156 (+156).** + +## Before acting on these numbers + +- **356 of 356 rows in the window came from a loopback address** (127.0.0.1 / ::1); every one of the 12035 addressed rows the table has ever held is loopback (13 carry no address). Either this host is the only audience, or the app sits behind a local proxy and `getRequestIP` records the proxy, not the visitor. Either way the address identifies nobody — "unique visitors" collapses to one host and per-channel reach cannot be read from it. +- **The download stage is one label, not two.** The landing-door route and the file route that serves the DMG both write `/api/downloads/thenook`, so "download click → DMG served" cannot be split: one served click writes two rows, a click that fails to resolve writes one, and a Sparkle update writes one. Of this window's 156 download rows, 156 are loopback and 117 are not browser user agents (curl, Sparkle, link-preview crawlers). +- **Checkout start is not recorded.** `POST /api/the-nook/checkout` — the call that actually creates the Stripe session — writes nothing to analytics; the only trace is the /checkout page view reported above, which is a page, not a started checkout. +- **There is no host dimension.** `VisitorAnalytics` records a path, not the site, so the Nook's landing page (`/` on nook.freno.me) is the same row as freno.me's root. "visit" above is site-wide page views, not Nook visits. +- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric. + +† Proxy or merged label — see *Before acting on these numbers* above. + +## Sources + +- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns. +- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel. +- Generated 2026-10-29T16:00:00Z. diff --git a/package.json b/package.json index 8ed8203..35ff3dd 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:coverage": "bun test --coverage", "perf": "bun run scripts/perf-test.ts", "perf:compare": "bun run scripts/perf-compare.ts", + "metrics:weekly": "bun --env-file=.env scripts/weekly-metrics.ts", "nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts" }, "dependencies": { diff --git a/scripts/weekly-metrics.ts b/scripts/weekly-metrics.ts new file mode 100644 index 0000000..f24c693 --- /dev/null +++ b/scripts/weekly-metrics.ts @@ -0,0 +1,153 @@ +import { createClient } from "@libsql/client/web"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { + getWeeklyReport, + renderWeeklyMarkdown, + type Connections +} from "~/server/weekly-metrics"; + +/** + * Write `marketing/metrics/weekly.md` from the live analytics and licence DBs. + * + * bun --env-file=.env scripts/weekly-metrics.ts + * bun --env-file=.env scripts/weekly-metrics.ts --days 14 --now 2026-11-05T09:00:00Z + * + * Required environment (all four; the analytics token also opens the licence + * DB, which is why the weekly job can run from a checkout that has no Stripe + * or Apple secrets): + * + * TURSO_DB_URL / TURSO_DB_TOKEN freno.me DB — VisitorAnalytics + * NOOK_DB_URL / NOOK_DB_TOKEN the-nook DB — trials, licenses, activations + * + * This script deliberately does not import `~/env/server`: that schema demands + * every production secret, and the metrics job needs two databases and nothing + * else. + * + * The output path is anchored at the repo root, so running it from any cwd + * writes the same file. + */ + +interface Options { + days: number; + now: Date; + out: string; + stdout: boolean; +} + +function usage(): string { + return [ + "Usage: bun --env-file=.env scripts/weekly-metrics.ts [options]", + "", + " --days N window length in days (default 7)", + " --now ISO end of the window (default: now)", + " --out PATH where to write (default: marketing/metrics/weekly.md)", + " --stdout print the report instead of writing a file" + ].join("\n"); +} + +function parseArgs(argv: string[]): Options { + const repoRoot = resolve(import.meta.dir, ".."); + const options: Options = { + days: 7, + now: new Date(), + out: resolve(repoRoot, "marketing/metrics/weekly.md"), + stdout: false + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]!; + const value = () => { + const next = argv[i + 1]; + if (next === undefined) throw new Error(`${arg} needs a value\n\n${usage()}`); + i += 1; + return next; + }; + + switch (arg) { + case "--days": { + const days = Number(value()); + if (!Number.isFinite(days) || days <= 0) { + throw new Error(`--days must be a positive number\n\n${usage()}`); + } + options.days = days; + break; + } + case "--now": { + const now = new Date(value()); + if (Number.isNaN(now.getTime())) { + throw new Error(`--now must be an ISO timestamp\n\n${usage()}`); + } + options.now = now; + break; + } + case "--out": + options.out = resolve(value()); + break; + case "--stdout": + options.stdout = true; + break; + case "-h": + case "--help": + console.log(usage()); + process.exit(0); + default: + throw new Error(`Unknown argument ${arg}\n\n${usage()}`); + } + } + + return options; +} + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value || value.trim() === "") { + throw new Error( + `${name} is not set. The weekly job needs TURSO_DB_URL, TURSO_DB_TOKEN, ` + + `NOOK_DB_URL and NOOK_DB_TOKEN — run it as ` + + `\`bun --env-file=.env scripts/weekly-metrics.ts\` with those four in .env.` + ); + } + return value; +} + +function connect(url: string, token: string) { + return createClient({ url, authToken: token }); +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + + const connections: Connections = { + analytics: connect(requiredEnv("TURSO_DB_URL"), requiredEnv("TURSO_DB_TOKEN")), + nook: connect(requiredEnv("NOOK_DB_URL"), requiredEnv("NOOK_DB_TOKEN")) + }; + + const report = await getWeeklyReport(connections, { + days: options.days, + now: options.now + }); + const markdown = renderWeeklyMarkdown(report); + + if (options.stdout) { + process.stdout.write(markdown); + return; + } + + await mkdir(dirname(options.out), { recursive: true }); + await writeFile(options.out, markdown, "utf8"); + + const f = report.counts; + console.log( + `wrote ${options.out}\n` + + `window ${report.window.start} → ${report.window.end}\n` + + `visit ${f.visit}, download ${f.download}, trial ${f.trial}, ` + + `checkout ${f.checkout}, purchase ${f.purchase}, activation ${f.activation}\n` + + `mover: ${report.mover ? `${report.mover.label} ${report.mover.before} → ${report.mover.now}` : "none"}` + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/server/weekly-metrics.test.ts b/src/server/weekly-metrics.test.ts new file mode 100644 index 0000000..d7321ec --- /dev/null +++ b/src/server/weekly-metrics.test.ts @@ -0,0 +1,381 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { Database } from "bun:sqlite"; +import { + VISITOR_ANALYTICS_COLUMNS, + VISITOR_ANALYTICS_DDL, + VISITOR_ANALYTICS_INSERT +} from "./analytics-schema"; +import { + NOOK_DOWNLOAD_PATH, + getWeeklyReport, + pickMover, + readCampaigns, + readFunnel, + readTraffic, + renderWeeklyMarkdown, + windowsFor, + type Connections, + type FunnelCounts, + type MetricsConn +} from "./weekly-metrics"; + +// Same reasoning as the backfill suite: the queries run real SQL, so an +// in-memory SQLite shaped like production is the only honest stand-in. A stub +// that returned canned rows would pass with a column or a label the live table +// does not have. + +const NOOK_DDL = [ + `CREATE TABLE trials ( + fingerprint TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + email TEXT + )`, + `CREATE TABLE licenses ( + id TEXT PRIMARY KEY, + key TEXT UNIQUE NOT NULL, + email TEXT NOT NULL, + stripe_session_id TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE activations ( + id TEXT PRIMARY KEY, + license_id TEXT NOT NULL, + device_fingerprint TEXT NOT NULL, + device_name TEXT NOT NULL, + activated_at TEXT NOT NULL, + deactivated_at TEXT + )` +]; + +function adapter(db: Database): MetricsConn { + return { + async execute(query) { + const args = (query.args ?? []) as never[]; + const statement = db.query(query.sql); + if (/^\s*(select|pragma|with)\b/i.test(query.sql)) { + return { rows: statement.all(...args) as unknown[], rowsAffected: 0 }; + } + return { + rows: [] as unknown[], + rowsAffected: statement.run(...args).changes + }; + } + }; +} + +let analyticsDb: Database; +let nookDb: Database; +let conns: Connections; + +const NOW = new Date("2026-10-29T16:00:00Z"); + +const row = ( + overrides: Record +): Record => ({ + id: crypto.randomUUID(), + user_id: null, + path: "/", + method: "GET", + referrer: null, + user_agent: "Mozilla/5.0 (Macintosh)", + ip_address: "203.0.113.9", + country: "GB", + device_type: "desktop", + browser: "safari", + os: "macos", + utm_source: null, + utm_medium: null, + utm_campaign: null, + duration_ms: null, + fcp: null, + lcp: null, + cls: null, + fid: null, + inp: null, + ttfb: null, + dom_load: null, + load_complete: null, + ...overrides +}); + +function insertAnalytics(overrides: Record): void { + const values = row(overrides); + analyticsDb + .prepare(VISITOR_ANALYTICS_INSERT) + .run(...(VISITOR_ANALYTICS_COLUMNS.map((column) => values[column]) as never[])); + + // `created_at` is not in VISITOR_ANALYTICS_COLUMNS (it defaults to now), so + // the window each row belongs to has to be set after the insert. + if (values.created_at) { + analyticsDb + .prepare(`UPDATE VisitorAnalytics SET created_at = ? WHERE id = ?`) + .run(values.created_at as string, values.id as string); + } +} + +// Inside the current window for NOW - 7 days: 2026-10-22 16:00 → 2026-10-29 16:00. +const THIS_WEEK = "2026-10-27 10:00:00"; +// Inside the previous window: 2026-10-15 16:00 → 2026-10-22 16:00. +const LAST_WEEK = "2026-10-18 10:00:00"; + +beforeEach(() => { + analyticsDb = new Database(":memory:"); + analyticsDb.run(VISITOR_ANALYTICS_DDL); + nookDb = new Database(":memory:"); + for (const ddl of NOOK_DDL) nookDb.run(ddl); + conns = { analytics: adapter(analyticsDb), nook: adapter(nookDb) }; +}); + +describe("windowsFor", () => { + it("puts the previous window directly before the current one", () => { + const windows = windowsFor(NOW, 7); + expect(windows.current).toEqual({ + start: "2026-10-22 16:00:00", + end: "2026-10-29 16:00:00" + }); + expect(windows.previous).toEqual({ + start: "2026-10-15 16:00:00", + end: "2026-10-22 16:00:00" + }); + }); +}); + +describe("readFunnel", () => { + it("counts each stage inside its window and nothing outside it", async () => { + // Page views count; API calls do not. + insertAnalytics({ path: "/", created_at: THIS_WEEK }); + insertAnalytics({ path: "/downloads", created_at: THIS_WEEK }); + insertAnalytics({ path: "/api/trpc/anything", created_at: THIS_WEEK }); + insertAnalytics({ path: "/", created_at: LAST_WEEK }); + + // The merged download label, click and served alike. + insertAnalytics({ path: NOOK_DOWNLOAD_PATH, created_at: THIS_WEEK }); + insertAnalytics({ path: NOOK_DOWNLOAD_PATH, created_at: THIS_WEEK }); + + // Checkout page doors, including the legacy /nook/checkout path. + insertAnalytics({ path: "/checkout", created_at: THIS_WEEK }); + insertAnalytics({ path: "/nook/checkout", created_at: THIS_WEEK }); + + // The licence DB stores ISO-8601 with milliseconds. + nookDb + .prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`) + .run("fp-1", "2026-10-28T20:28:46.806Z"); + nookDb + .prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`) + .run("fp-old", "2026-08-26T19:26:12.791Z"); + nookDb + .prepare( + `INSERT INTO licenses (id, key, email, stripe_session_id, created_at) + VALUES (?, ?, ?, ?, ?)` + ) + .run("lic-1", "NOOK-1", "buyer@example.com", "cs_1", "2026-10-25T12:00:00.000Z"); + nookDb + .prepare( + `INSERT INTO activations (id, license_id, device_fingerprint, device_name, activated_at) + VALUES (?, ?, ?, ?, ?)` + ) + .run("act-1", "lic-1", "device-1", "Hermes", "2026-10-25T12:05:00.000Z"); + + const window = windowsFor(NOW, 7).current; + const funnel = await readFunnel(conns, window); + + // "/checkout" and "/nook/checkout" are page views too, so they land in + // both the visit count and the checkout count. + expect(funnel).toEqual({ + visit: 4, + download: 2, + trial: 1, + checkout: 2, + purchase: 1, + activation: 1 + }); + }); + + it("reports zeros against an empty database", async () => { + const window = windowsFor(NOW, 7).current; + expect(await readFunnel(conns, window)).toEqual({ + visit: 0, + download: 0, + trial: 0, + checkout: 0, + purchase: 0, + activation: 0 + }); + }); +}); + +describe("readTraffic", () => { + it("separates loopback and automation from browser traffic", async () => { + insertAnalytics({ + path: NOOK_DOWNLOAD_PATH, + ip_address: "127.0.0.1", + user_agent: "curl/8.7.1", + created_at: THIS_WEEK + }); + insertAnalytics({ + path: NOOK_DOWNLOAD_PATH, + ip_address: "::1", + user_agent: "The Nook/0.13.4 Sparkle/2.9.6", + created_at: THIS_WEEK + }); + insertAnalytics({ + path: NOOK_DOWNLOAD_PATH, + ip_address: "203.0.113.9", + user_agent: "Mozilla/5.0 (Macintosh) Safari/605.1.15", + created_at: THIS_WEEK + }); + insertAnalytics({ path: "/", ip_address: "203.0.113.9", created_at: THIS_WEEK }); + + const traffic = await readTraffic(conns, windowsFor(NOW, 7).current); + + expect(traffic).toEqual({ + analyticsRows: 4, + loopbackRows: 2, + allTimeRows: 4, + allTimeAddressedRows: 4, + allTimeLoopbackRows: 2, + downloadRows: 3, + downloadLoopbackRows: 2, + downloadNonBrowserRows: 2 + }); + }); +}); + +describe("readCampaigns", () => { + it("groups tagged rows, excludes untagged, and orders by volume", async () => { + insertAnalytics({ + path: "/", + utm_source: "hn", + utm_medium: "post", + utm_campaign: "launch", + created_at: THIS_WEEK + }); + insertAnalytics({ + path: "/", + utm_source: "hn", + utm_medium: "post", + utm_campaign: "launch", + created_at: THIS_WEEK + }); + insertAnalytics({ + path: "/", + utm_source: "r_macapps", + utm_campaign: "week2", + created_at: THIS_WEEK + }); + insertAnalytics({ path: "/", created_at: THIS_WEEK }); + + const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current); + + expect(campaigns).toEqual([ + { source: "hn", medium: "post", campaign: "launch", count: 2 }, + { source: "r_macapps", medium: null, campaign: "week2", count: 1 } + ]); + }); + + it("caps the list at ten rows", async () => { + for (let i = 0; i < 12; i += 1) { + insertAnalytics({ + path: "/", + utm_source: `source-${i}`, + created_at: THIS_WEEK + }); + } + + const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current); + expect(campaigns.length).toBe(10); + }); +}); + +describe("pickMover", () => { + const base: FunnelCounts = { + visit: 0, + download: 0, + trial: 0, + checkout: 0, + purchase: 0, + activation: 0 + }; + + it("picks the stage with the largest absolute change", () => { + const mover = pickMover( + { ...base, visit: 61, download: 156, trial: 2 }, + { ...base, visit: 56 } + ); + expect(mover).toEqual({ + key: "download", + label: "download click → DMG served (one label)", + now: 156, + before: 0, + delta: 156 + }); + }); + + it("breaks ties toward the earlier stage", () => { + const mover = pickMover( + { ...base, visit: 5, download: 5 }, + { ...base, visit: 0, download: 0 } + ); + expect(mover?.key).toBe("visit"); + }); + + it("returns null when nothing moved", () => { + expect(pickMover({ ...base, visit: 3 }, { ...base, visit: 3 })).toBeNull(); + }); +}); + +describe("getWeeklyReport / renderWeeklyMarkdown", () => { + it("reports a week in the agreed stage order with the computed caveats", async () => { + insertAnalytics({ path: "/", created_at: THIS_WEEK }); + insertAnalytics({ + path: NOOK_DOWNLOAD_PATH, + ip_address: "127.0.0.1", + user_agent: "curl/8.7.1", + created_at: THIS_WEEK + }); + insertAnalytics({ + path: NOOK_DOWNLOAD_PATH, + ip_address: "127.0.0.1", + user_agent: "curl/8.7.1", + created_at: THIS_WEEK + }); + nookDb + .prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`) + .run("fp-1", "2026-10-28T20:28:46.806Z"); + + const report = await getWeeklyReport(conns, { now: NOW, days: 7 }); + const markdown = renderWeeklyMarkdown(report); + + expect(report.mover?.key).toBe("download"); + expect(markdown).toContain( + "Window: **2026-10-22 16:00:00 → 2026-10-29 16:00:00 UTC** (7 days)" + ); + expect(markdown).toContain("| visit (page views) | 1 | 0 | +1 |"); + expect(markdown.indexOf("visit (page views)")).toBeLessThan( + markdown.indexOf("download click → DMG served") + ); + expect(markdown.indexOf("download click → DMG served")).toBeLessThan( + markdown.indexOf("trial started") + ); + expect(markdown).toContain("**download click → DMG served (one label): 0 → 2 (+2).**"); + expect(markdown).toContain("2 of 3 rows in the window came from a loopback address"); + expect(markdown).toContain("Checkout start is not recorded"); + expect(markdown).toContain("There is no host dimension"); + }); + + it("says nothing moved rather than inventing a winner", async () => { + const report = await getWeeklyReport(conns, { now: NOW, days: 7 }); + const markdown = renderWeeklyMarkdown(report); + + expect(report.mover).toBeNull(); + expect(markdown).toContain("_Nothing moved: every stage held its previous-week value._"); + expect(markdown).toContain("_No tagged traffic in the window._"); + }); + + it("rejects a window that is not a positive number of days", async () => { + await expect(getWeeklyReport(conns, { now: NOW, days: 0 })).rejects.toThrow( + "days must be a positive number" + ); + }); +}); diff --git a/src/server/weekly-metrics.ts b/src/server/weekly-metrics.ts new file mode 100644 index 0000000..7ab1b72 --- /dev/null +++ b/src/server/weekly-metrics.ts @@ -0,0 +1,591 @@ +/** + * The weekly funnel reading: `marketing/metrics/weekly.md`'s data half. + * + * The funnel the marketing cluster agreed on is + * + * visit → download click → DMG served → trial started → checkout started + * → purchase → activation + * + * and this module reads it from the two live databases, plus the campaign + * dimension the /analytics page already exposes through + * `getAnalyticsSummary.topCampaigns`. + * + * Two properties matter more than the numbers: + * + * - It takes both connections as arguments. `~/server/analytics` imports the + * full server env (Stripe keys, Apple secret, licence key …); the weekly + * job only needs the two databases, and must be runnable from a checkout + * whose `.env` carries nothing else. The SQL here is the same shape as the + * analytics summaries, so the weekly read and the dashboard cannot drift + * into different label sets. + * - Every stage carries what it actually measured. Two of the seven agreed + * stages share one label in production today and a third is only a page-view + * proxy; the report states that next to the number instead of inventing a + * figure (see `renderWeeklyMarkdown`). + * + * No `~/env`, no `~/server/database`: the caller owns the connections, the + * tests own an in-memory SQLite, and the script owns the libsql clients. + */ + +import { DOWNLOAD_PATH_PREFIX } from "~/lib/download-asset"; + +/** The one asset key the Nook lands under — `downloadPath("thenook")`. */ +export const NOOK_DOWNLOAD_PATH = `${DOWNLOAD_PATH_PREFIX}thenook`; + +/** An `execute`-shaped connection: the libsql client and the test adapter both fit. */ +export interface MetricsConn { + execute(query: { + sql: string; + args?: any[]; + }): Promise<{ rows: unknown[]; rowsAffected?: number }>; +} + +export interface Connections { + /** freno.me Turso DB — VisitorAnalytics. */ + analytics: MetricsConn; + /** the-nook Turso DB — trials, licenses, activations. */ + nook: MetricsConn; +} + +export type StageKey = + | "visit" + | "download" + | "trial" + | "checkout" + | "purchase" + | "activation"; + +export interface StageDef { + key: StageKey; + label: string; + /** True when the number is a proxy or a merged label, not a clean stage. */ + approximate: boolean; +} + +/** + * The seven agreed stages. `download` deliberately carries two of them: the + * door and the file route both write `/api/downloads/thenook`, so a click and + * the DMG it fetched are one label in the data (proved in the report's caveats). + */ +export const STAGES: StageDef[] = [ + { key: "visit", label: "visit (page views)", approximate: false }, + { + key: "download", + label: "download click → DMG served (one label)", + approximate: true + }, + { key: "trial", label: "trial started", approximate: false }, + { + key: "checkout", + label: "checkout page visit", + approximate: true + }, + { key: "purchase", label: "purchase (licence issued)", approximate: false }, + { key: "activation", label: "activation", approximate: false } +]; + +export interface FunnelCounts { + visit: number; + download: number; + trial: number; + checkout: number; + purchase: number; + activation: number; +} + +export interface CampaignRow { + source: string | null; + medium: string | null; + campaign: string | null; + count: number; +} + +/** Traffic-shape facts the report has to state next to the funnel. */ +export interface WindowTraffic { + analyticsRows: number; + loopbackRows: number; + /** All-time row counts, so "every row ever" is a checked claim, not a guess. */ + allTimeRows: number; + allTimeAddressedRows: number; + allTimeLoopbackRows: number; + downloadRows: number; + downloadLoopbackRows: number; + downloadNonBrowserRows: number; +} + +export interface Mover { + key: StageKey; + label: string; + now: number; + before: number; + delta: number; +} + +export interface WeeklyReport { + window: { start: string; end: string; days: number }; + previous: { start: string; end: string }; + counts: FunnelCounts; + previousCounts: FunnelCounts; + campaigns: CampaignRow[]; + taggedRows: number; + traffic: WindowTraffic; + mover: Mover | null; + generatedAt: string; +} + +/** Loopback addresses — every row the live table has ever held. */ +const LOOPBACK = ["127.0.0.1", "::1", "::ffff:127.0.0.1"]; + +/** SQLite's `datetime()` string, UTC: `YYYY-MM-DD HH:MM:SS`. */ +export function sqlTime(date: Date): string { + return date.toISOString().slice(0, 19).replace("T", " "); +} + +export interface Windows { + current: { start: string; end: string }; + previous: { start: string; end: string }; +} + +/** + * The current window is `[now - days, now)` and the previous one sits directly + * before it, so a weekly read always compares like with like. Boundaries are + * passed as SQL parameters rather than `datetime('now', …)` so the file a run + * writes is reproducible and the two queries cannot straddle a second. + */ +export function windowsFor(now: Date, days: number): Windows { + const span = days * 24 * 60 * 60 * 1000; + const end = now; + const start = new Date(end.getTime() - span); + const previousEnd = start; + const previousStart = new Date(previousEnd.getTime() - span); + + return { + current: { start: sqlTime(start), end: sqlTime(end) }, + previous: { start: sqlTime(previousStart), end: sqlTime(previousEnd) } + }; +} + +function toNumber(value: unknown): number { + if (typeof value === "number") return value; + if (typeof value === "bigint") return Number(value); + if (typeof value === "string") return Number(value) || 0; + return 0; +} + +async function scalar( + conn: MetricsConn, + sql: string, + args: any[] +): Promise { + const result = await conn.execute({ sql, args }); + const row = result.rows[0] as Record | undefined; + if (!row) return 0; + return toNumber(Object.values(row)[0]); +} + +async function countInWindow( + conn: MetricsConn, + sql: string, + start: string, + end: string +): Promise { + return scalar(conn, sql, [start, end]); +} + +/** One window's funnel. Two databases, one call. */ +export async function readFunnel( + conns: Connections, + window: { start: string; end: string } +): Promise { + const { analytics, nook } = conns; + const { start, end } = window; + + const visit = await countInWindow( + analytics, + `SELECT COUNT(*) AS n FROM VisitorAnalytics + WHERE created_at >= ? AND created_at < ? + AND path NOT LIKE '/api/%'`, + start, + end + ); + + const download = await scalar( + analytics, + `SELECT COUNT(*) AS n FROM VisitorAnalytics + WHERE created_at >= ? AND created_at < ? + AND path = ?`, + [start, end, NOOK_DOWNLOAD_PATH] + ); + + const checkout = await countInWindow( + analytics, + `SELECT COUNT(*) AS n FROM VisitorAnalytics + WHERE created_at >= ? AND created_at < ? + AND (path LIKE '/checkout%' OR path LIKE '/nook/checkout%')`, + start, + end + ); + + // The licence DB stores ISO-8601 (`2026-10-28T20:28:46.806Z`); the analytics + // DB stores `YYYY-MM-DD HH:MM:SS`. `datetime()` normalises both sides so a + // boundary seconds apart cannot include or drop a row on string comparison. + const trial = await scalar( + nook, + `SELECT COUNT(*) AS n FROM trials + WHERE datetime(started_at) >= datetime(?) AND datetime(started_at) < datetime(?)`, + [start, end] + ); + + const purchase = await scalar( + nook, + `SELECT COUNT(*) AS n FROM licenses + WHERE datetime(created_at) >= datetime(?) AND datetime(created_at) < datetime(?)`, + [start, end] + ); + + const activation = await scalar( + nook, + `SELECT COUNT(*) AS n FROM activations + WHERE datetime(activated_at) >= datetime(?) AND datetime(activated_at) < datetime(?)`, + [start, end] + ); + + return { + visit, + download, + trial, + checkout, + purchase, + activation + }; +} + +/** + * Traffic shape for one window: how much of it came from this host, and how + * much of the download label was automation. Without these two facts the + * numbers read as demand, which they are not. + */ +export async function readTraffic( + conns: Connections, + window: { start: string; end: string } +): Promise { + const { analytics } = conns; + const { start, end } = window; + const placeholders = LOOPBACK.map(() => "?").join(", "); + + const allTimeRows = await scalar( + analytics, + `SELECT COUNT(*) AS n FROM VisitorAnalytics`, + [] + ); + + const allTimeAddressedRows = await scalar( + analytics, + `SELECT COUNT(*) AS n FROM VisitorAnalytics WHERE ip_address IS NOT NULL`, + [] + ); + + const allTimeLoopbackRows = await scalar( + analytics, + `SELECT COUNT(*) AS n FROM VisitorAnalytics + WHERE ip_address IN (${placeholders})`, + [...LOOPBACK] + ); + + const analyticsRows = await countInWindow( + analytics, + `SELECT COUNT(*) AS n FROM VisitorAnalytics + WHERE created_at >= ? AND created_at < ?`, + start, + end + ); + + const loopbackRows = await scalar( + analytics, + `SELECT COUNT(*) AS n FROM VisitorAnalytics + WHERE created_at >= ? AND created_at < ? + AND ip_address IN (${placeholders})`, + [start, end, ...LOOPBACK] + ); + + const downloadRows = await scalar( + analytics, + `SELECT COUNT(*) AS n FROM VisitorAnalytics + WHERE created_at >= ? AND created_at < ? AND path = ?`, + [start, end, NOOK_DOWNLOAD_PATH] + ); + + const downloadLoopbackRows = await scalar( + analytics, + `SELECT COUNT(*) AS n FROM VisitorAnalytics + WHERE created_at >= ? AND created_at < ? AND path = ? + AND ip_address IN (${placeholders})`, + [start, end, NOOK_DOWNLOAD_PATH, ...LOOPBACK] + ); + + // Anything whose UA is not a browser engine: curl, Sparkle's updater, + // link-preview crawlers. A real install click arrives as `Mozilla/5.0 …`. + const downloadNonBrowserRows = await scalar( + analytics, + `SELECT COUNT(*) AS n FROM VisitorAnalytics + WHERE created_at >= ? AND created_at < ? AND path = ? + AND (user_agent IS NULL OR user_agent NOT LIKE 'Mozilla/%')`, + [start, end, NOOK_DOWNLOAD_PATH] + ); + + return { + analyticsRows, + loopbackRows, + allTimeRows, + allTimeAddressedRows, + allTimeLoopbackRows, + downloadRows, + downloadLoopbackRows, + downloadNonBrowserRows + }; +} + +/** + * The campaign dimension, grouped exactly as `getAnalyticsSummary.topCampaigns` + * groups it: top 10 by volume, untagged rows excluded (the untagged bucket is + * a different question and would otherwise swallow the list). + */ +export async function readCampaigns( + analytics: MetricsConn, + window: { start: string; end: string } +): Promise { + const result = await analytics.execute({ + sql: `SELECT utm_source, utm_medium, utm_campaign, COUNT(*) AS count + FROM VisitorAnalytics + WHERE created_at >= ? AND created_at < ? + AND (utm_source IS NOT NULL OR utm_medium IS NOT NULL OR utm_campaign IS NOT NULL) + GROUP BY utm_source, utm_medium, utm_campaign + ORDER BY count DESC + LIMIT 10`, + args: [window.start, window.end] + }); + + return result.rows.map((raw) => { + const row = raw as Record; + return { + source: (row.utm_source as string | null) ?? null, + medium: (row.utm_medium as string | null) ?? null, + campaign: (row.utm_campaign as string | null) ?? null, + count: toNumber(row.count) + }; + }); +} + +/** + * "The one thing that moved": the stage with the largest absolute change + * against the previous window. Ties go to the earlier stage, so a week when + * nothing moved returns null rather than a made-up winner. + */ +export function pickMover( + current: FunnelCounts, + previous: FunnelCounts +): Mover | null { + let best: Mover | null = null; + + for (const stage of STAGES) { + const delta = current[stage.key] - previous[stage.key]; + if (delta === 0) continue; + if (!best || Math.abs(delta) > Math.abs(best.delta)) { + best = { + key: stage.key, + label: stage.label, + now: current[stage.key], + before: previous[stage.key], + delta + }; + } + } + + return best; +} + +export interface ReportOptions { + /** End of the current window; defaults to the wall clock. */ + now?: Date; + /** Window length in days; defaults to 7. */ + days?: number; +} + +/** Read everything `.md` needs, in one call. */ +export async function getWeeklyReport( + conns: Connections, + options: ReportOptions = {} +): Promise { + const days = options.days ?? 7; + if (!Number.isFinite(days) || days <= 0) { + throw new Error(`days must be a positive number, got ${days}`); + } + + const now = options.now ?? new Date(); + const windows = windowsFor(now, days); + + const [counts, previousCounts, traffic, campaigns] = await Promise.all([ + readFunnel(conns, windows.current), + readFunnel(conns, windows.previous), + readTraffic(conns, windows.current), + readCampaigns(conns.analytics, windows.current) + ]); + + return { + window: { ...windows.current, days }, + previous: windows.previous, + counts, + previousCounts, + campaigns, + taggedRows: campaigns.reduce((sum, row) => sum + row.count, 0), + traffic, + mover: pickMover(counts, previousCounts), + generatedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z") + }; +} + +function cell(value: string | null): string { + return value === null || value === "" ? "—" : value; +} + +function signed(value: number): string { + if (value === 0) return "0"; + return value > 0 ? `+${value}` : `−${Math.abs(value)}`; +} + +/** + * Render the weekly file. Interpretation lives in the CMO's read; this renderer + * only states what the data is, what moved, and what the numbers cannot say. + * The Gaps section is not boilerplate: each bullet is computed from the window + * it describes, so it empties itself if the underlying hole is ever fixed. + */ +export function renderWeeklyMarkdown(report: WeeklyReport): string { + const { counts, previousCounts, traffic } = report; + const lines: string[] = []; + + lines.push("# Weekly funnel snapshot"); + lines.push(""); + lines.push( + `Window: **${report.window.start} → ${report.window.end} UTC** (${report.window.days} days). ` + + `Previous: ${report.previous.start} → ${report.previous.end} UTC.` + ); + lines.push(""); + lines.push( + "Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated " + + "weekly, never hand-edited. The analysis is the CMO's." + ); + lines.push(""); + + lines.push("## Funnel"); + lines.push(""); + lines.push("| Stage | This week | Last week | Change |"); + lines.push("|---|---:|---:|---:|"); + for (const stage of STAGES) { + const now = counts[stage.key]; + const before = previousCounts[stage.key]; + const label = stage.approximate ? `${stage.label} †` : stage.label; + lines.push(`| ${label} | ${now} | ${before} | ${signed(now - before)} |`); + } + lines.push(""); + + lines.push("## Campaigns"); + lines.push(""); + if (report.campaigns.length === 0) { + lines.push("_No tagged traffic in the window._"); + } else { + lines.push( + "Top 10 by volume, untagged excluded — the same grouping " + + "`getAnalyticsSummary.topCampaigns` returns." + ); + lines.push(""); + lines.push("| Source | Medium | Campaign | Rows |"); + lines.push("|---|---|---|---:|"); + for (const row of report.campaigns) { + lines.push( + `| ${cell(row.source)} | ${cell(row.medium)} | ${cell(row.campaign)} | ${row.count} |` + ); + } + } + lines.push(""); + lines.push( + `${report.taggedRows} of ${traffic.analyticsRows} rows in the window carry ` + + `campaign tags; the rest are untagged and therefore unattributed.` + ); + lines.push(""); + + lines.push("## The one thing that moved"); + lines.push(""); + if (!report.mover) { + lines.push("_Nothing moved: every stage held its previous-week value._"); + } else { + const mover = report.mover; + lines.push( + `**${mover.label}: ${mover.before} → ${mover.now} (${signed(mover.delta)}).**` + ); + } + lines.push(""); + + lines.push("## Before acting on these numbers"); + lines.push(""); + + if (traffic.analyticsRows > 0 && traffic.loopbackRows > 0) { + lines.push( + `- **${traffic.loopbackRows} of ${traffic.analyticsRows} rows in the window came from a loopback address** ` + + `(127.0.0.1 / ::1)` + + (traffic.allTimeAddressedRows > 0 && + traffic.allTimeAddressedRows === traffic.allTimeLoopbackRows + ? `; every one of the ${traffic.allTimeAddressedRows} addressed rows the table ` + + `has ever held is loopback (${traffic.allTimeRows - traffic.allTimeAddressedRows} carry no address)` + : `; across the table's history, ${traffic.allTimeLoopbackRows} of ` + + `${traffic.allTimeAddressedRows} addressed rows are loopback`) + + `. Either this host is the only audience, or the app sits behind a local proxy ` + + `and \`getRequestIP\` records the proxy, not the visitor. Either way the address ` + + `identifies nobody — "unique visitors" collapses to one host and per-channel ` + + `reach cannot be read from it.` + ); + } + + if (traffic.downloadRows > 0) { + lines.push( + `- **The download stage is one label, not two.** The landing-door route and the file ` + + `route that serves the DMG both write \`${NOOK_DOWNLOAD_PATH}\`, so ` + + `"download click → DMG served" cannot be split: one served click writes two rows, ` + + `a click that fails to resolve writes one, and a Sparkle update writes one. ` + + `Of this window's ${traffic.downloadRows} download rows, ` + + `${traffic.downloadLoopbackRows} are loopback and ` + + `${traffic.downloadNonBrowserRows} are not browser user agents (curl, Sparkle, ` + + `link-preview crawlers).` + ); + } + + lines.push( + `- **Checkout start is not recorded.** \`POST /api/the-nook/checkout\` — the call that ` + + `actually creates the Stripe session — writes nothing to analytics; the only trace is ` + + `the /checkout page view reported above, which is a page, not a started checkout.` + ); + + lines.push( + `- **There is no host dimension.** \`VisitorAnalytics\` records a path, not the site, so ` + + `the Nook's landing page (\`/\` on nook.freno.me) is the same row as freno.me's root. ` + + `"visit" above is site-wide page views, not Nook visits.` + ); + + lines.push( + `- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature ` + + `Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.` + ); + lines.push(""); + + lines.push( + "† Proxy or merged label — see *Before acting on these numbers* above." + ); + lines.push(""); + lines.push("## Sources"); + lines.push(""); + lines.push("- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns."); + lines.push("- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel."); + lines.push(`- Generated ${report.generatedAt}.`); + lines.push(""); + + return lines.join("\n"); +}