metrics: stand up the weekly funnel job (FRE-202)
`marketing/metrics/weekly.md` has been a placeholder since the marketing tree landed — it promised a funnel and the one thing that moved, and nothing produced it. This adds the producer: one command reads the live databases and rewrites the file. `scripts/weekly-metrics.ts` writes `marketing/metrics/weekly.md` from `VisitorAnalytics` (visits, downloads, campaigns) and the-nook's `trials`, `licenses`, `activations` (the paid funnel). It needs four env vars — TURSO_DB_URL/TOKEN and NOOK_DB_URL/TOKEN — and imports no server env, so the job runs from a checkout that has no Stripe or Apple secrets. `src/server/weekly-metrics.ts` holds the queries and the renderer, taking both connections as arguments; the tests run them against an in-memory SQLite shaped like production, the same way the backfill suite does. The campaign breakdown groups exactly as `getAnalyticsSummary.topCampaigns` does (top 10, untagged excluded), so the weekly read and /analytics cannot drift into different label sets. The first real report says more about the instrumentation than about demand, and the renderer computes that rather than asserting it: - every addressed row the table has ever held (12,035 of 12,048) is loopback, so "unique visitors" is one host and per-channel reach cannot be read; - the landing door and the file route both write `/api/downloads/thenook`, so "download click" and "DMG served" are one label — one served click writes two rows, a failed click one, a Sparkle update one; - `POST /api/the-nook/checkout` records nothing, so checkout start is only a page-view proxy; - `VisitorAnalytics` has no host dimension, so the Nook's `/` is freno.me's `/`. Those three gaps are reported beside the numbers, not papered over. Week 0 (2026-10-22 → 2026-10-29): visit 61, download 156, trial 2, checkout 0, purchase 0, activation 0 — and the one thing that moved is the download stage, 0 → 156, because the label was introduced that week. Tests: `bun test src/server/weekly-metrics.test.ts` — 12 pass, 0 fail. The full suite's 15 failures are pre-existing: this checkout's .env lacks NOOK_LICENSE_PRIVATE_KEY, NOOK_STRIPE_SK/WEBHOOK/PRICE and LINEAGE_JWT_SECRET, and every failure is that env validation. Scripts/ is gitignored, so `scripts/weekly-metrics.ts` was added with `-f`, the way the existing tracked scripts are. FRE-202
This commit is contained in:
@@ -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/`.
|
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.
|
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.
|
||||||
|
|||||||
@@ -1,4 +1,51 @@
|
|||||||
# Weekly funnel snapshot
|
# Weekly funnel snapshot
|
||||||
|
|
||||||
Generated weekly from the analytics DB + `trials`/`licenses`/`activations`.
|
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.
|
||||||
Format: the funnel numbers, what moved, and the one thing worth acting on.
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"test:coverage": "bun test --coverage",
|
"test:coverage": "bun test --coverage",
|
||||||
"perf": "bun run scripts/perf-test.ts",
|
"perf": "bun run scripts/perf-test.ts",
|
||||||
"perf:compare": "bun run scripts/perf-compare.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"
|
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
153
scripts/weekly-metrics.ts
Normal file
153
scripts/weekly-metrics.ts
Normal file
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
381
src/server/weekly-metrics.test.ts
Normal file
381
src/server/weekly-metrics.test.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
): Record<string, unknown> => ({
|
||||||
|
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<string, unknown>): 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
591
src/server/weekly-metrics.ts
Normal file
591
src/server/weekly-metrics.ts
Normal file
@@ -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<number> {
|
||||||
|
const result = await conn.execute({ sql, args });
|
||||||
|
const row = result.rows[0] as Record<string, unknown> | undefined;
|
||||||
|
if (!row) return 0;
|
||||||
|
return toNumber(Object.values(row)[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countInWindow(
|
||||||
|
conn: MetricsConn,
|
||||||
|
sql: string,
|
||||||
|
start: string,
|
||||||
|
end: string
|
||||||
|
): Promise<number> {
|
||||||
|
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<FunnelCounts> {
|
||||||
|
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<WindowTraffic> {
|
||||||
|
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<CampaignRow[]> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<WeeklyReport> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user