Compare commits
10 Commits
faq/from-o
...
c7071b0e32
| Author | SHA1 | Date | |
|---|---|---|---|
| c7071b0e32 | |||
| 7fde259e0b | |||
| 25ec84ceb4 | |||
| dd368d21e7 | |||
|
|
89f56fc78a | ||
|
|
d44133fcb3 | ||
|
|
578ee58f9c | ||
|
|
d2c2e6a4ee | ||
|
|
c718370674 | ||
|
|
210a7e6e75 |
@@ -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,6 +1,9 @@
|
|||||||
# Remote SSH copy — HELD, do not publish
|
# Remote SSH copy — HELD, do not publish
|
||||||
|
|
||||||
**Status:** removed from the live site on 2026-10-29 in the FRE-323 correction.
|
**Status:** removed from the `freno-dev` source on 2026-10-29 in the FRE-323
|
||||||
|
correction, and **not yet off the live site** — the deploy is blocked on a write
|
||||||
|
credential to `mikefreno/freno-dev` (card `82d1e1b7`). `nook.freno.me` still
|
||||||
|
serves every sentence below until that push or a Vercel deploy lands.
|
||||||
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
|
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
|
||||||
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
|
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
|
||||||
verifies that flow, and a recording shows a remote agent joining the island.
|
verifies that flow, and a recording shows a remote agent joining the island.
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim
|
|||||||
unverified and demo-gated. The CTO named it the largest launch risk because it
|
unverified and demo-gated. The CTO named it the largest launch risk because it
|
||||||
was already in front of buyers.
|
was already in front of buyers.
|
||||||
|
|
||||||
So the landing page now says only what the product can back today: local agents,
|
So the `freno-dev` source now says only what the product can back today: local
|
||||||
the card, and the notch. The remote story is not deleted — it is parked here with
|
agents, the card, and the notch. That correction is committed locally and is
|
||||||
the FRE-320/FRE-205 work so it can return the day the one-click install ships and
|
**not deployed** — the live page still carries the remote sentences until the
|
||||||
is recorded.
|
push in FRE-323 card `82d1e1b7` lands. The remote story is not deleted — it is
|
||||||
|
parked here with the FRE-320/FRE-205 work so it can return the day the one-click
|
||||||
|
install ships and is recorded.
|
||||||
|
|
||||||
## What has to be true before it returns
|
## What has to be true before it returns
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -4,13 +4,39 @@ Every drafted asset draws its voice, claims, and constraints from this file. If
|
|||||||
artifact invents its own framing, fix the artifact or fix this file — never both
|
artifact invents its own framing, fix the artifact or fix this file — never both
|
||||||
silently diverging.
|
silently diverging.
|
||||||
|
|
||||||
Primary source: `~/Code/the-nook/docs/marketing-strategy.md`.
|
**Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 count
|
||||||
|
correction, against the CTO's verdicts ([FRE-315], [FRE-322], [FRE-354],
|
||||||
|
[FRE-997]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
|
||||||
|
launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is
|
||||||
|
**14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous
|
||||||
|
version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
|
||||||
|
no longer exists; the verdicts below are the source of truth it was pointing at.
|
||||||
|
|
||||||
|
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
|
||||||
|
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
|
||||||
|
[FRE-354]: https://paperclip.freno.me/FRE/issues/FRE-354
|
||||||
|
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
|
||||||
|
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
|
||||||
|
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
|
||||||
|
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
|
||||||
|
|
||||||
## The sentence
|
## The sentence
|
||||||
|
|
||||||
> **Your Mac and your agents, under control.** The Nook puts your whole coding-agent
|
There is no single approved public sentence yet, and saying otherwise is the one
|
||||||
> fleet in the notch — thirteen agents — so you see what is running,
|
mistake this file exists to prevent. What exists is a **launch claim set** — two
|
||||||
> unblock it in one tap, and keep the machine from overheating while it works.
|
sentences, both from one 15-second take, recorded as the chosen route by the Chief
|
||||||
|
of Staff on 2026-10-29 ([FRE-346] `decision` §2–§4):
|
||||||
|
|
||||||
|
| Slot | Exact sentence | Status today |
|
||||||
|
|---|---|---|
|
||||||
|
| Launch line | **"Every agent you're running reports into one notch, and the one waiting on you pins above the rest."** | Verified by code ([FRE-322] §3). Needs the one-Mac take before it is publishable. |
|
||||||
|
| Second line, same take | **"Approve an agent's tool call from your Mac's notch, and the agent carries on."** | Needs the demo ([FRE-322] §1). The same take settles it. |
|
||||||
|
|
||||||
|
Working line for internal writing, not for publication:
|
||||||
|
|
||||||
|
> **Your Mac and your agents, under control.** One notch for the whole fleet: you
|
||||||
|
> see what is running, you unblock what is waiting, and the machine stays cool
|
||||||
|
> while it works.
|
||||||
|
|
||||||
## The wedge
|
## The wedge
|
||||||
|
|
||||||
@@ -22,15 +48,18 @@ The thing to say over and over, in every channel, in slightly different words:
|
|||||||
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
|
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
|
||||||
Allow, work resumes. Ten seconds, no explanation needed.
|
Allow, work resumes. Ten seconds, no explanation needed.
|
||||||
|
|
||||||
## The bundling reframe
|
## The consolidation reframe
|
||||||
|
|
||||||
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
||||||
$10 app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
||||||
|
|
||||||
> One $10 app instead of a notch toy, a fan utility, a battery limiter, a menu-bar
|
> One app instead of a notch toy, a fan utility, a battery limiter, a menu-bar stats
|
||||||
> stats strip, and a notification hack. Same one-time purchase, no subscription.
|
> strip, and a notification hack. One purchase, no subscription.
|
||||||
|
|
||||||
Price rises to $15 at 1.0 — dated and honest, not a permanent beta.
|
**No price, no discount and no future price in copy.** The price is $10, it stays
|
||||||
|
$10, and the dated rise to $15 is withdrawn (Chief of Staff, 2026-10-29 04:01). A
|
||||||
|
price change is a Chief of Staff + sponsor call; it is never a marketing decision
|
||||||
|
and never a launch asset.
|
||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
@@ -48,30 +77,49 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
|
|||||||
community replies written by a human, where quoting what a user said about another
|
community replies written by a human, where quoting what a user said about another
|
||||||
tool is normal conversation.
|
tool is normal conversation.
|
||||||
|
|
||||||
### 2. A claim requires a test to exist
|
### 2. A claim needs a verdict, not just a test
|
||||||
|
|
||||||
Copy that says "13 agents" or "no data leaves the device" must have a test backing it.
|
Copy that says "every agent", "13 agents", "no data leaves the device" must point at
|
||||||
If a claim cannot be pointed at code or a test, it does not go in copy.
|
both a test **and** a CTO verdict naming the exact sentence. A passing test is not a
|
||||||
|
verdict; a verdict is not a recording. If a sentence cannot be traced to a row in the
|
||||||
|
tables below, it does not go in copy.
|
||||||
|
|
||||||
### 3. Honest privacy scoping
|
### 3. Honest privacy scoping
|
||||||
|
|
||||||
Telemetry and the trial email **do** leave the device. The defensible claim is:
|
Telemetry and the licence check **do** leave the device: Sentry runs on **every
|
||||||
|
launch, unconditionally** (with the Mac's IP address via `sendDefaultPii`), and the
|
||||||
|
trial check sends `IOPlatformUUID` and the device name to `freno.me` ([FRE-322]
|
||||||
|
§2.4, corrected by [FRE-354] `(b)`). Never publish, in any wording:
|
||||||
|
|
||||||
> **No prompt or code content leaves the device.**
|
- "no data leaves the device" / "local-only" / "no telemetry" / "no account"
|
||||||
|
- "nothing leaves your Mac"
|
||||||
|
|
||||||
Never "no data leaves the device".
|
The one favourable engine sentence — about the **hook and bridge only**, never
|
||||||
|
"The Nook" — as corrected by [FRE-354] `(j)`: the hook and bridge **are not a cloud
|
||||||
|
service**. Do not write "talk to nothing but your Mac" (too absolute: the
|
||||||
|
`NOOK_PORTS` fan-out resolves `host.docker.internal` and the default gateway, and
|
||||||
|
`defaultGateway()` opens a no-packet UDP socket to `8.8.8.8:80`), and do not narrow
|
||||||
|
it to "over SSH" (a tailnet peer dials directly).
|
||||||
|
|
||||||
|
It is verified as a code fact ([FRE-322] §2, [FRE-354] `(j)`), and it is still
|
||||||
|
**held** until the disclosures land: Sentry gated behind a preference or removed,
|
||||||
|
and `/privacy` + `/faq` corrected against the egress inventory (patch on branch
|
||||||
|
`faq/from-objections-2026-10-29`, verified by [FRE-354]). A true sentence next to
|
||||||
|
false ones is worse than no sentence.
|
||||||
|
|
||||||
### 4. Sell what works today
|
### 4. Sell what works today
|
||||||
|
|
||||||
Phone approvals are deferred until a shipped iOS app exists — no "(coming soon)"
|
An iPhone + Apple Watch companion **is** in users' hands as a TestFlight beta
|
||||||
parentheticals. The launch story is the approval card: an agent blocked on a
|
(`ios-v0.3.2`, 2026-10-22; [FRE-354] open question). That makes one thing true and
|
||||||
permission prompt, unblocked from the Mac's notch, and work resumes.
|
one thing forbidden: say "TestFlight beta" when the phone exists in a sentence at
|
||||||
|
all, and never claim approvals-from-phone as a shipped capability without its own
|
||||||
|
CTO verdict — no "(coming soon)" parentheticals either way.
|
||||||
|
|
||||||
Remote SSH is **held**, not approved. The one-click install has not shipped and
|
Remote SSH is **held**, not approved: the one-click install has not
|
||||||
has not been recorded (FRE-320, FRE-205), and the current path needs hand-edited
|
been verified end to end ([FRE-315] §1) and the shipped path needs hand-edited
|
||||||
`~/.ssh/config` and a one-time `ssh-copy-id` (FRE-320). Its copy is parked in
|
`~/.ssh/config` and a one-time `ssh-copy-id` ([FRE-320]). Its copy stays parked in
|
||||||
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow and a
|
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a
|
||||||
demo exists.
|
recording shows a remote agent joining the notch.
|
||||||
|
|
||||||
### 5. Humans hold every account
|
### 5. Humans hold every account
|
||||||
|
|
||||||
@@ -79,28 +127,30 @@ No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
|
|||||||
without disclosure. `approved/` **is the only thing a human ever publishes from** —
|
without disclosure. `approved/` **is the only thing a human ever publishes from** —
|
||||||
nothing posts itself.
|
nothing posts itself.
|
||||||
|
|
||||||
## Approved claims (each backed by code)
|
## Claims: status at 2026-11-03
|
||||||
|
|
||||||
| Claim | Backing |
|
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
|
||||||
|---|---|
|
|
||||||
| Thirteen hooked agents, one glance | `AgentSpec.all` — claude, codex, opencode, pi, omp, muse, hermes, openclaw, zai, gemini, cline, qwen, deepseek |
|
|
||||||
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) |
|
|
||||||
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) |
|
|
||||||
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout |
|
|
||||||
| macOS 14+ | 27 source files use the `@Observable` framework |
|
|
||||||
| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) |
|
|
||||||
|
|
||||||
## Held claims (not publishable)
|
| Claim | Status | Evidence / next step |
|
||||||
|
|
||||||
| Claim | Why it is held | What has to be true first |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Remote agents over SSH, one click to install | The one-click path is app-UI-only and needs hand-edited `~/.ssh/config` plus a one-time `ssh-copy-id`; no demo exists (FRE-320). FRE-315 and FRE-322 hold it unverified and demo-gated. | FRE-320/FRE-205 ship a genuinely one-click install, the CTO verifies it, and a recording shows a remote agent joining the island. Copy parked in `marketing/drafts/remote-ssh/`. |
|
| Approve an agent's tool call from the notch, and it carries on | **Needs demo** — not publishable | [FRE-322] §1. `AgentLiveTurnTests` has never run on a signed-in host; one observed local run settles it. Say "an agent", never "any agent", never "always". |
|
||||||
|
| Every agent you're running reports into one notch; the blocked one pins above the rest | **Verified by code, gated on the take** | [FRE-322] §3. Same one-Mac take as above. No count in the sentence. |
|
||||||
|
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
|
||||||
|
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
|
||||||
|
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
|
||||||
|
| Agent coverage numbers ("eighteen agents", "18 agents") | **Publishable at 18** | [FRE-997]: the /agents table now lists every row of `AgentSpec.all` and `agents.test.ts` asserts equality with the 18-name registry in registry order, so the landing bullet, the marquee chip and the /agents copy say 18. No other number is publishable. |
|
||||||
|
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
|
||||||
|
| Remote agents over SSH, one click to install | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. |
|
||||||
|
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
|
||||||
|
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
|
||||||
|
|
||||||
## Voice
|
## Voice
|
||||||
|
|
||||||
- Short, concrete, no hype adjectives. Numbers over adjectives ("thirteen agents",
|
- Short, concrete, no hype adjectives. Numbers over adjectives — the only count
|
||||||
"one tap", "$10 once").
|
that ships is eighteen, and it is the registry's own size (see the coverage row
|
||||||
- The notch/island is the product's home; agents are the product's point.
|
above).
|
||||||
|
- The notch is the product's home; agents are the product's point.
|
||||||
- Never apologize for being a utility. The utility is the point.
|
- Never apologize for being a utility. The utility is the point.
|
||||||
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
|
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
|
||||||
"game-changer", "seamlessly". Unverifiable superlatives generally.
|
"game-changer", "seamlessly", any unverifiable superlative — and the specific
|
||||||
|
banned sentences in rule 3 and the table above.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -851,7 +851,7 @@ export default function FeatureBreakdowns() {
|
|||||||
title="Your whole fleet, in one pixel strip"
|
title="Your whole fleet, in one pixel strip"
|
||||||
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
||||||
bullets={[
|
bullets={[
|
||||||
"13 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
|
"18 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
|
||||||
"Custom agents: your own coding agent can wire itself in from the published contract",
|
"Custom agents: your own coding agent can wire itself in from the published contract",
|
||||||
"Slot rankings — you choose which data earns the notch",
|
"Slot rankings — you choose which data earns the notch",
|
||||||
"You don't need a notch — external displays get the same island, floating top-center"
|
"You don't need a notch — external displays get the same island, floating top-center"
|
||||||
|
|||||||
@@ -722,7 +722,7 @@ function LayoutMock() {
|
|||||||
/* ── Marquee ─────────────────────────────────────────────────────── */
|
/* ── Marquee ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const CHIPS_A = [
|
const CHIPS_A = [
|
||||||
"13 agents",
|
"18 agents",
|
||||||
"Custom agents",
|
"Custom agents",
|
||||||
"Six chiptunes",
|
"Six chiptunes",
|
||||||
"Focus-aware slots",
|
"Focus-aware slots",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Regression test for the /agents roster (NOOK-336).
|
* Regression test for the /agents roster and its coverage count.
|
||||||
*
|
*
|
||||||
* The page mirrors the app's registry, `AgentSpec.all` in
|
* The page mirrors the app's registry, `AgentSpec.all` in
|
||||||
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
|
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
* them. The registry lives in another repo, so the expected roster is
|
* them. The registry lives in another repo, so the expected roster is
|
||||||
* restated here; when an agent is added there, add it here and on the page.
|
* restated here; when an agent is added there, add it here and on the page.
|
||||||
*
|
*
|
||||||
|
* FRE-997 restored the count. The page said thirteen while the registry held
|
||||||
|
* eighteen, so the count was dropped rather than corrected (FRE-359 verdict B);
|
||||||
|
* now that all eighteen rows are on the page, the roster check is an equality
|
||||||
|
* check again and the page states eighteen. A subset check would let a shipped
|
||||||
|
* agent go missing behind a passing suite, which is the NOOK-336 failure.
|
||||||
|
*
|
||||||
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
|
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
@@ -16,7 +22,10 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
|
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
|
||||||
|
|
||||||
/** AgentSpec.all display names, in registry order. */
|
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||||
|
const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
|
||||||
|
/** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */
|
||||||
const REGISTRY = [
|
const REGISTRY = [
|
||||||
"Claude Code",
|
"Claude Code",
|
||||||
"Codex",
|
"Codex",
|
||||||
@@ -30,25 +39,31 @@ const REGISTRY = [
|
|||||||
"Gemini CLI",
|
"Gemini CLI",
|
||||||
"Cline",
|
"Cline",
|
||||||
"Qwen Code",
|
"Qwen Code",
|
||||||
"DeepSeek"
|
"DeepSeek",
|
||||||
|
"Kimi",
|
||||||
|
"GitHub Copilot CLI",
|
||||||
|
"Crush",
|
||||||
|
"Cursor",
|
||||||
|
"Amp"
|
||||||
];
|
];
|
||||||
|
|
||||||
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen"];
|
|
||||||
|
|
||||||
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
|
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
|
||||||
|
|
||||||
describe("Nook /agents roster (NOOK-336)", () => {
|
describe("Nook /agents roster", () => {
|
||||||
it("lists every registry agent, in registry order", () => {
|
it("lists every registry agent, once, in registry order", () => {
|
||||||
expect(entries).toEqual(REGISTRY);
|
expect(entries).toEqual(REGISTRY);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("states the same count everywhere it names one", () => {
|
it("states the registry's size", () => {
|
||||||
const word = NUMBER_WORDS[entries.length - 12];
|
expect(entries.length).toBe(18);
|
||||||
for (const other of NUMBER_WORDS.filter((w) => w !== word)) {
|
expect(COPY).toContain("all eighteen");
|
||||||
expect(SOURCE.toLowerCase()).not.toContain(other);
|
expect(COPY).toContain("The 18 agents");
|
||||||
}
|
expect(COPY).toContain("eighteen coding agents");
|
||||||
expect(SOURCE).toContain(`all ${word}`);
|
});
|
||||||
expect(SOURCE).toContain(`The ${entries.length} agents`);
|
|
||||||
expect(SOURCE).toContain(`watches ${word} coding agents`);
|
it("carries no stale count", () => {
|
||||||
|
expect(COPY.toLowerCase()).not.toContain("thirteen");
|
||||||
|
expect(COPY).not.toMatch(/\b13 agents\b/);
|
||||||
|
expect(COPY).not.toContain("all thirteen");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* `/agents` on the Nook subdomain — supported agents and their event coverage.
|
* `/agents` on the Nook subdomain — all eighteen agents in the registry and
|
||||||
|
* each row's event coverage.
|
||||||
*
|
*
|
||||||
* The registry below mirrors `AgentSpec.all` in the app
|
* The registry below mirrors `AgentSpec.all` in the app
|
||||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
|
* (~/Code/the-nook/Sources/NookCore/Agents/AgentRegistry.swift) and each
|
||||||
* installed hook surface (hook/setup.go) or plugin event subscription
|
* agent's installed hook surface (hook/setup.go) or plugin event subscription
|
||||||
* (plugins/*), normalized onto the shared event vocabulary
|
* (plugins/*), normalized onto the shared event vocabulary
|
||||||
* (~/Code/the-nook/docs/agent-interface.md). Copy follows
|
* (~/Code/the-nook/docs/agent-interface.md). Copy follows
|
||||||
* marketing/positioning.md: category nouns, honest scoping, no unverifiable
|
* marketing/positioning.md: category nouns, honest scoping, no unverifiable
|
||||||
@@ -259,6 +260,92 @@ const AGENTS: AgentEntry[] = [
|
|||||||
],
|
],
|
||||||
gating:
|
gating:
|
||||||
"Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
|
"Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Kimi",
|
||||||
|
wiring: "Hook rules in its TOML config, one per event",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"AgentStart",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PermissionResolved",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"Stop",
|
||||||
|
"SessionError",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"PreCompact",
|
||||||
|
"Notification"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — every event appears as a card; its PreToolUse fires before its own permission check, so the agent keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GitHub Copilot CLI",
|
||||||
|
wiring: "One hook file of ours in its hooks directory",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"Stop",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"ErrorOccurred",
|
||||||
|
"PreCompact",
|
||||||
|
"Notification"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — every event appears as a card; its permission request runs before its own policy check, so the agent keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Crush",
|
||||||
|
wiring:
|
||||||
|
"One entry in its JSONC config, the only event it documents",
|
||||||
|
events: ["PreToolUse"],
|
||||||
|
gating:
|
||||||
|
"Observation — tool calls appear as cards; its own permission prompts decide"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cursor",
|
||||||
|
wiring:
|
||||||
|
"Hook entries in its hooks.json, merged with your own. No permission hook, on purpose",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"Stop",
|
||||||
|
"SessionError",
|
||||||
|
"AfterAgentResponse",
|
||||||
|
"SubagentStop",
|
||||||
|
"PreCompact"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — a permission hook that stays silent blocks the action here, so Cursor keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Amp",
|
||||||
|
wiring: "In-process extension package",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"Stop",
|
||||||
|
"SessionError"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — its plugin API has no permission-wait event, so the agent keeps its own prompting"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -283,8 +370,8 @@ export default function NookAgentsPage() {
|
|||||||
<EdgeCacheHeaders maxAge={300} />
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Supported agents"
|
title="Supported agents"
|
||||||
description="Every coding agent The Nook supports — all thirteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
description="Every coding agent The Nook supports — all eighteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
||||||
ogTitle="The 13 agents The Nook supports, and what each hook sees"
|
ogTitle="The 18 agents The Nook supports, and what each hook sees"
|
||||||
/>
|
/>
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||||
@@ -292,7 +379,7 @@ export default function NookAgentsPage() {
|
|||||||
Supported agents
|
Supported agents
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
|
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
|
||||||
The Nook watches thirteen coding agents in one place — your menu bar and
|
The Nook watches eighteen coding agents in one place — your menu bar and
|
||||||
notch show every session that is running, thinking, or blocked, and a
|
notch show every session that is running, thinking, or blocked, and a
|
||||||
card answers permission prompts without leaving your keyboard.
|
card answers permission prompts without leaving your keyboard.
|
||||||
</p>
|
</p>
|
||||||
@@ -300,8 +387,7 @@ export default function NookAgentsPage() {
|
|||||||
Each agent reports its own set of events. "Allow / Deny from a card"
|
Each agent reports its own set of events. "Allow / Deny from a card"
|
||||||
means the agent hands the decision to The Nook while it waits;
|
means the agent hands the decision to The Nook while it waits;
|
||||||
"Observe" means every event still appears live, but the agent keeps
|
"Observe" means every event still appears live, but the agent keeps
|
||||||
its own prompting. Agents can run on this Mac or on remote machines
|
its own prompting.
|
||||||
over SSH — remote sessions report into the same grid.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
|||||||
126
src/routes/nook/remote-claims.test.ts
Normal file
126
src/routes/nook/remote-claims.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Regression test for the removed remote / SSH claims and the removed agent
|
||||||
|
* count (FRE-323, FRE-359 verdict B).
|
||||||
|
*
|
||||||
|
* FRE-323 took the remote story off the site because it was unverified and
|
||||||
|
* demo-gated (FRE-315, FRE-320, FRE-322 §3 correction 4): the "one click
|
||||||
|
* installs the nook-hook" flow is app-UI-only, needs a hand-edited
|
||||||
|
* `~/.ssh/config` for any host not already in ssh config or on the tailnet,
|
||||||
|
* needs a one-time `ssh-copy-id` because the app runs ssh with
|
||||||
|
* `BatchMode=yes`, and has no manual fallback for minting the per-host
|
||||||
|
* credential. FRE-359 verdict B removed the coverage count for the same
|
||||||
|
* reason: the /agents table lists 13 of the registry's 18 rows.
|
||||||
|
*
|
||||||
|
* FRE-997 corrects the second half without touching the first. The count was
|
||||||
|
* stale, not unverifiable: `AgentSpec.all` holds eighteen, /agents now lists
|
||||||
|
* all eighteen and `agents.test.ts` asserts that roster as an equality again.
|
||||||
|
* So the two completeness phrasings FRE-359 banned ("every coding agent",
|
||||||
|
* "Supported agents") no longer rest on a 13-of-18 gap — the ban existed
|
||||||
|
* because the page claimed the set and listed two thirds of it. They are out
|
||||||
|
* of `BANNED_COVERAGE` on this branch for that reason alone; the stale counts
|
||||||
|
* stay banned, and every FRE-323 remote sentence stays banned, because this
|
||||||
|
* branch still carries the FRE-323 site state.
|
||||||
|
*
|
||||||
|
* The copy was not thrown away — it is parked in
|
||||||
|
* `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts
|
||||||
|
* it is still there so nobody deletes the parked story as stale. It goes back
|
||||||
|
* on the site only when the one-click install ships and is recorded.
|
||||||
|
*
|
||||||
|
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
|
||||||
|
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
|
||||||
|
*
|
||||||
|
* Deliberate exceptions, not audited here — both were weighed with the Chief
|
||||||
|
* of Staff at the go/no-go and left in place on purpose:
|
||||||
|
* - `src/routes/nook/agents.tsx` Muse row: "This Mac only — remote hosts skip
|
||||||
|
* it" is a limitation disclosure, not a capability claim.
|
||||||
|
* - `src/components/nook/IslandExtras.tsx` "Tailscale hosts" chip: adjacent to
|
||||||
|
* the removed remote story, but tailnet host lookup does exist. Raised on
|
||||||
|
* FRE-323 rather than widened into this correction.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "../../..");
|
||||||
|
|
||||||
|
/** Shipped page and component source. Tests and drafts may quote the old copy. */
|
||||||
|
const AUDITED = [
|
||||||
|
"src/routes/nook/index.tsx",
|
||||||
|
"src/routes/nook/agents.tsx",
|
||||||
|
"src/routes/nook/faq.tsx",
|
||||||
|
"src/components/nook/FeatureCollage.tsx",
|
||||||
|
"src/components/nook/FeatureBreakdowns.tsx",
|
||||||
|
"src/components/nook/IslandExtras.tsx"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Every sentence FRE-323 ordered off the site. */
|
||||||
|
const BANNED_REMOTE_CLAIMS = [
|
||||||
|
"Agents on other machines",
|
||||||
|
"one click installs the nook-hook",
|
||||||
|
"Any SSH box",
|
||||||
|
"Works across every hooked agent, local or remote",
|
||||||
|
"Remote sessions are ordinary rows",
|
||||||
|
"One-click SSH setup",
|
||||||
|
"remote machines over SSH",
|
||||||
|
"this Mac or remote machines"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** FRE-359 verdict B: no stale count, in any form.
|
||||||
|
*
|
||||||
|
* "every coding agent" and "Supported agents" left this list with FRE-997:
|
||||||
|
* the page now lists all eighteen registry rows and `agents.test.ts` asserts
|
||||||
|
* that equality, so the completeness claim is backed rather than incomplete. */
|
||||||
|
const BANNED_COVERAGE = [
|
||||||
|
"all thirteen",
|
||||||
|
"thirteen coding agents",
|
||||||
|
"13 agents"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||||
|
function copyOf(file: string): string {
|
||||||
|
return readFileSync(join(ROOT, file), "utf8")
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("remote claims stay off the site (FRE-323)", () => {
|
||||||
|
for (const file of AUDITED) {
|
||||||
|
it(`${file} does not restore a removed remote sentence`, () => {
|
||||||
|
const copy = copyOf(file);
|
||||||
|
for (const phrase of BANNED_REMOTE_CLAIMS) {
|
||||||
|
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("/agents no longer offers remote sessions", () => {
|
||||||
|
const copy = copyOf("src/routes/nook/agents.tsx");
|
||||||
|
expect(copy).not.toContain("remote sessions report into the same grid");
|
||||||
|
expect(copy).not.toContain("on this Mac or on remote");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the parked copy still exists, so restoring the story is a revert and not a rewrite", () => {
|
||||||
|
const parked = readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8");
|
||||||
|
for (const phrase of ["Agents on other machines", "Any SSH box, one click away"]) {
|
||||||
|
expect(parked).toContain(phrase);
|
||||||
|
}
|
||||||
|
expect(readFileSync(join(ROOT, "marketing/drafts/remote-ssh/rationale.md"), "utf8")).toContain(
|
||||||
|
"FRE-320"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the agent count is corrected, not dropped (FRE-997)", () => {
|
||||||
|
for (const file of AUDITED) {
|
||||||
|
it(`${file} states no stale count`, () => {
|
||||||
|
const copy = copyOf(file);
|
||||||
|
for (const phrase of BANNED_COVERAGE) {
|
||||||
|
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("/agents states the registry's size", () => {
|
||||||
|
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Supported agents");
|
||||||
|
});
|
||||||
|
});
|
||||||
139
src/routes/smarter-remote/privacy.tsx
Normal file
139
src/routes/smarter-remote/privacy.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* A Smarter Remote privacy policy — `freno.me/smarter-remote/privacy`.
|
||||||
|
*
|
||||||
|
* Linked from the iOS app's paywall (App Review 3.1.2). Reflects what the app
|
||||||
|
* actually does: TV control stays on the local network, pairing data stays on
|
||||||
|
* the phone, purchases go through Apple and RevenueCat, and sharing is a QR
|
||||||
|
* code passed between phones.
|
||||||
|
*/
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
|
|
||||||
|
export default function SmarterRemotePrivacyPolicy() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
|
<PageHead
|
||||||
|
title="A Smarter Remote Privacy Policy"
|
||||||
|
description="Privacy policy for A Smarter Remote, a universal TV remote app that works over your local Wi-Fi network."
|
||||||
|
/>
|
||||||
|
<div class="bg-base">
|
||||||
|
<div class="min-h-screen px-[8vw] py-[8vh]">
|
||||||
|
<div class="py-4 text-xl">A Smarter Remote's Privacy Policy</div>
|
||||||
|
<div class="py-2">Last Updated: November 2, 2026</div>
|
||||||
|
<div class="py-2">
|
||||||
|
Welcome to A Smarter Remote ('We', 'Us',
|
||||||
|
'Our'). The app controls your TVs over your own Wi-Fi
|
||||||
|
network. It has no accounts, no ads, and no analytics. This policy
|
||||||
|
explains the little data it does handle.
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">1.</span> Data We Collect
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Your TVs:</div> The app finds TVs on
|
||||||
|
your local network and stores their names, network addresses,
|
||||||
|
and platform on your phone. Pairing codes and tokens are kept
|
||||||
|
in your phone's Keychain. This data is sent only to the
|
||||||
|
TV itself, over your local network. It never reaches us.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Purchases:</div> Payments are handled
|
||||||
|
by Apple. We use RevenueCat to check whether you own A Smarter
|
||||||
|
Remote Pro. RevenueCat receives an anonymous app user ID, your
|
||||||
|
purchase and subscription history, and basic device and app
|
||||||
|
details such as OS version, app version, and region. It does
|
||||||
|
not receive your name, email, or payment details.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Voice input:</div> If you use voice
|
||||||
|
search, your speech is converted to text by Apple's
|
||||||
|
speech recognition. Apple may process it on its servers. The
|
||||||
|
resulting text is sent only to your TV.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Sharing:</div> If you share a TV with
|
||||||
|
someone, its name, address, and platform are put into a QR
|
||||||
|
code on your screen for them to scan. It goes from one phone
|
||||||
|
to the other and does not pass through our servers.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">2.</span> How We Use It
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
Data about your TVs is used only to control them. Purchase
|
||||||
|
records are used only to unlock Pro. We do not use any of it for
|
||||||
|
advertising, and we do not sell it.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">3.</span> Local Network Access
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
iOS asks for permission before the app can use your local
|
||||||
|
network. The app needs it to find and control your TVs. If you
|
||||||
|
decline, the app cannot reach your TVs. You can change this in
|
||||||
|
Settings > Privacy & Security > Local Network.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">4.</span> Deleting Your Data
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
Removing a TV in the app deletes its stored details and
|
||||||
|
credentials from your phone. Deleting the app removes all of
|
||||||
|
them. Subscriptions are managed and cancelled in your App Store
|
||||||
|
account settings.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">5.</span> Children's Privacy
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
The app does not knowingly collect personal information from
|
||||||
|
anyone, including children under 13.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">6.</span> Changes to the Privacy Policy
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
We may update this policy periodically. Any changes are posted on
|
||||||
|
this page, so please review it from time to time.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">7.</span> Contact Us
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
If you have any questions about this policy, you can contact us{" "}
|
||||||
|
<A href="/contact" class="text-blue hover-underline-animation">
|
||||||
|
here
|
||||||
|
</A>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/routes/smarter-remote/terms.tsx
Normal file
141
src/routes/smarter-remote/terms.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* A Smarter Remote terms of use — `freno.me/smarter-remote/terms`.
|
||||||
|
*
|
||||||
|
* Linked from the iOS app's paywall (App Review 3.1.2). Builds on Apple's
|
||||||
|
* standard EULA and adds the Pro subscription and lifetime purchase terms.
|
||||||
|
*/
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
|
|
||||||
|
export default function SmarterRemoteTerms() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
|
<PageHead
|
||||||
|
title="A Smarter Remote Terms of Use"
|
||||||
|
description="Terms of use for A Smarter Remote and its Pro subscription."
|
||||||
|
/>
|
||||||
|
<div class="bg-base">
|
||||||
|
<div class="min-h-screen px-[8vw] py-[8vh]">
|
||||||
|
<div class="py-4 text-xl">A Smarter Remote's Terms of Use</div>
|
||||||
|
<div class="py-2">Last Updated: November 2, 2026</div>
|
||||||
|
<div class="py-2">
|
||||||
|
These terms cover your use of the A Smarter Remote app ('the
|
||||||
|
App'). By using the App you agree to them. The App is licensed
|
||||||
|
to you under Apple's{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-blue hover-underline-animation"
|
||||||
|
>
|
||||||
|
Licensed Application End User License Agreement
|
||||||
|
</a>
|
||||||
|
, and the terms below add to it.
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">1.</span> A Smarter Remote Pro
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) What it includes:</div> The free App
|
||||||
|
controls one TV. Pro removes that limit and unlocks the
|
||||||
|
features marked Pro in the App.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Yearly subscription:</div> Payment is
|
||||||
|
charged to your Apple account when you confirm the purchase,
|
||||||
|
or when any free trial ends. The subscription renews
|
||||||
|
automatically at the same price unless you cancel at least 24
|
||||||
|
hours before the end of the current period. Your account is
|
||||||
|
charged for renewal within 24 hours before the period ends.
|
||||||
|
Any unused part of a free trial is forfeited when you buy a
|
||||||
|
subscription.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Lifetime purchase:</div> A one-time
|
||||||
|
payment that unlocks Pro with no recurring charge.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Managing and refunds:</div> You can
|
||||||
|
manage or cancel a subscription in your App Store account
|
||||||
|
settings. Refunds are handled by Apple under its policies.
|
||||||
|
Restore Purchases in the App re-unlocks Pro on another device
|
||||||
|
signed in to the same Apple account.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">2.</span> Your TVs
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
The App controls TVs through the network features their makers
|
||||||
|
provide. Which features work depends on your TV's model and
|
||||||
|
software, which can change without notice. We do not guarantee
|
||||||
|
that every feature works on every TV. Only control TVs you own
|
||||||
|
or have permission to use.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">3.</span> No Warranty
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
The App is provided 'as is', without warranties of any
|
||||||
|
kind, to the extent the law allows. To the extent the law allows,
|
||||||
|
we are not liable for indirect or consequential damages arising
|
||||||
|
from your use of the App.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">4.</span> Privacy
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
How the App handles data is described in the{" "}
|
||||||
|
<A
|
||||||
|
href="/smarter-remote/privacy"
|
||||||
|
class="text-blue hover-underline-animation"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</A>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">5.</span> Changes to These Terms
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
We may update these terms periodically. Any changes are posted on
|
||||||
|
this page. Continuing to use the App after a change means you
|
||||||
|
accept the new terms.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">6.</span> Contact Us
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
If you have any questions about these terms, you can contact us{" "}
|
||||||
|
<A href="/contact" class="text-blue hover-underline-animation">
|
||||||
|
here
|
||||||
|
</A>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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