n-seo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.env.example +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/bin/n-seo.mjs +310 -0
  5. package/docs/ADDING-A-SITE.md +82 -0
  6. package/docs/ARCHITECTURE.md +213 -0
  7. package/docs/DEPLOY.md +300 -0
  8. package/docs/FAQ.md +93 -0
  9. package/docs/INSTANCE.md +365 -0
  10. package/docs/MCP.md +104 -0
  11. package/docs/OPERATING-RULES.md +106 -0
  12. package/docs/PLAYBOOK.md +122 -0
  13. package/docs/PRD.md +249 -0
  14. package/docs/RELEASING.md +189 -0
  15. package/docs/SCHEDULING.md +104 -0
  16. package/docs/SETUP-GOOGLE.md +215 -0
  17. package/docs/examples/campaign.json +59 -0
  18. package/docs/examples/draft.md +43 -0
  19. package/docs/screenshots/overview.png +0 -0
  20. package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
  21. package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
  22. package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
  23. package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
  24. package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
  25. package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
  26. package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
  27. package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
  28. package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
  29. package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
  30. package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
  31. package/ingest/analyze_ga4.py +79 -0
  32. package/ingest/analyze_gsc.py +136 -0
  33. package/ingest/analyze_metadata.py +158 -0
  34. package/ingest/analyze_trends.py +145 -0
  35. package/ingest/google_auth.py +238 -0
  36. package/ingest/http_util.py +87 -0
  37. package/ingest/pull_ga4.py +107 -0
  38. package/ingest/pull_gsc.py +111 -0
  39. package/ingest/pull_index_status.py +179 -0
  40. package/ingest/pull_timeseries.py +130 -0
  41. package/ingest/seo_config.py +213 -0
  42. package/n-seo.config.example.json +110 -0
  43. package/ops/__pycache__/daily.cpython-313.pyc +0 -0
  44. package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
  45. package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
  46. package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
  47. package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
  48. package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
  49. package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
  50. package/ops/__pycache__/llm.cpython-313.pyc +0 -0
  51. package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
  52. package/ops/__pycache__/publish.cpython-313.pyc +0 -0
  53. package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
  54. package/ops/daily.py +250 -0
  55. package/ops/daily_diff.py +151 -0
  56. package/ops/demo_data.py +529 -0
  57. package/ops/doctor.py +266 -0
  58. package/ops/export_static.py +125 -0
  59. package/ops/hn_digest.py +169 -0
  60. package/ops/indexnow.py +107 -0
  61. package/ops/install-launchd.sh +76 -0
  62. package/ops/llm.py +139 -0
  63. package/ops/mcp-smoke-stdio.mjs +61 -0
  64. package/ops/opportunity_scan.py +185 -0
  65. package/ops/publish.py +158 -0
  66. package/ops/reddit_digest.py +168 -0
  67. package/ops/templates/n-seo-daily.service +11 -0
  68. package/ops/templates/n-seo-daily.timer +11 -0
  69. package/ops/templates/n-seo-dashboard.service +15 -0
  70. package/ops/templates/n-seo.cron +3 -0
  71. package/ops/templates/n-seo.daily.plist +29 -0
  72. package/ops/templates/n-seo.dashboard.plist +22 -0
  73. package/package.json +77 -0
  74. package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
  75. package/probes/site_probe.py +201 -0
  76. package/public/favicon.svg +6 -0
  77. package/public/styles.css +632 -0
  78. package/src/actions.ts +255 -0
  79. package/src/backlog.ts +197 -0
  80. package/src/config.ts +220 -0
  81. package/src/data.ts +895 -0
  82. package/src/insights.ts +22 -0
  83. package/src/mcp-stdio.ts +21 -0
  84. package/src/mcp.ts +490 -0
  85. package/src/server.tsx +260 -0
  86. package/src/settings.tsx +329 -0
  87. package/src/views.tsx +1487 -0
  88. package/tsconfig.json +15 -0
package/src/views.tsx ADDED
@@ -0,0 +1,1487 @@
1
+ /** Server-rendered views (hono/jsx). */
2
+ import type { FC, PropsWithChildren } from "hono/jsx";
3
+ import { SITES, config, loadConfig, USING_EXAMPLE_CONFIG, type SiteCfg } from "./config.js";
4
+ import * as data from "./data.js";
5
+ import { actionsFor, allActions, type Action } from "./actions.js";
6
+ import { insights as loadInsights } from "./insights.js";
7
+
8
+ const pct = (x: number, digits = 1) => `${(100 * x).toFixed(digits)}%`;
9
+ const num = (x: number) => Math.round(x).toLocaleString("en-US");
10
+
11
+ /** The n-seo mark: the rising line breaking out of the plot, on brand teal.
12
+ * Kept in step with public/favicon.svg and the marketing site. */
13
+ export const Mark: FC<{ size?: number }> = ({ size = 22 }) => (
14
+ <svg class="brand-mark" width={size} height={size} viewBox="0 0 300 300" fill="none" aria-hidden="true">
15
+ <path d="M300 0H0V300H300V0Z" fill="#00E5B9" />
16
+ <path d="M300 239.902H32.0074V255.489H300V239.902Z" fill="#00FFCE" />
17
+ <path d="M55.7684 202.351L118.59 139.53L160.47 181.41L244.232 97.6489" stroke="#001769" stroke-width="27.4428" stroke-linecap="round" stroke-linejoin="round" />
18
+ <path d="M181.411 97.6489H244.232V160.47" stroke="#001769" stroke-width="27.4428" stroke-linecap="round" stroke-linejoin="round" />
19
+ </svg>
20
+ );
21
+
22
+ /** The rarely-visited diagnostics, folded behind one nav item. */
23
+ const SYSTEM_NAV = [
24
+ { key: "indexing", href: "/indexing", label: "Indexing" },
25
+ { key: "probes", href: "/probes", label: "Probes" },
26
+ { key: "logs", href: "/logs", label: "Logs" },
27
+ { key: "settings", href: "/settings", label: "Settings" },
28
+ ];
29
+
30
+ export const Layout: FC<PropsWithChildren<{ title: string; active: string }>> = ({
31
+ title,
32
+ active,
33
+ children,
34
+ }) => {
35
+ loadConfig(); // refresh USING_EXAMPLE_CONFIG
36
+ const cfg = config();
37
+ const sites = cfg.sites;
38
+ return (
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="utf-8" />
42
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
43
+ <title>{title}</title>
44
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
45
+ <link rel="stylesheet" href={`/styles.css?v=${data.styleVersion()}`} />
46
+ </head>
47
+ <body>
48
+ <header class="topbar">
49
+ <a href="/" class="brand"><Mark /> <span class="brand-name">{cfg.name}</span> <span class="brand-sub">n-seo</span></a>
50
+ <nav>
51
+ <a href="/" class={active === "overview" ? "on" : ""}>Overview</a>
52
+ <a href="/actions" class={active === "actions" ? "on" : ""}>Actions</a>
53
+ <a href="/insights" class={active === "insights" ? "on" : ""}>Insights</a>
54
+ <a href="/trends" class={active === "trends" ? "on" : ""}>Trends</a>
55
+ <a href="/content" class={active === "content" ? "on" : ""}>Content</a>
56
+ <details class="nav-menu">
57
+ <summary class={sites.some((s) => s.host === active) ? "on" : ""}>Sites ▾</summary>
58
+ <div class="nav-panel">
59
+ {sites.map((s) => (
60
+ <a href={`/site/${s.host}`} class={active === s.host ? "on" : ""}>{s.host}</a>
61
+ ))}
62
+ {sites.length === 0 && <span class="nav-empty">no sites configured</span>}
63
+ </div>
64
+ </details>
65
+ {/* Indexing, Probes, Logs and Settings are all "how is the machine
66
+ doing" pages you visit rarely. Ten top-level items read as a
67
+ wall; these four fold into one. */}
68
+ <details class="nav-menu">
69
+ <summary class={SYSTEM_NAV.some((s) => s.key === active) ? "on" : ""}>System ▾</summary>
70
+ <div class="nav-panel">
71
+ {SYSTEM_NAV.map((s) => (
72
+ <a href={s.href} class={active === s.key ? "on" : ""}>{s.label}</a>
73
+ ))}
74
+ </div>
75
+ </details>
76
+ </nav>
77
+ {/* Empty here on purpose. ops/export_static.py replaces this span
78
+ with a sign-out link when the mirror sits behind an auth proxy
79
+ (modules.staticExport.signOutUrl); the live dashboard has
80
+ nothing to sign out of. */}
81
+ <span id="export-slot"></span>
82
+ </header>
83
+ {USING_EXAMPLE_CONFIG && (
84
+ <div class="notice-bar">
85
+ Running on the example config — <a href="/settings">open Settings</a> to create yours (or copy <code>n-seo.config.example.json</code> to <code>n-seo.config.json</code>).
86
+ </div>
87
+ )}
88
+ <main>{children}</main>
89
+ <footer>
90
+ Local controller · refreshed by <code>ops/daily.py</code> · queue in <code>config/backlog.json</code> · nothing here posts or publishes for you
91
+ </footer>
92
+ <script dangerouslySetInnerHTML={{ __html: `document.addEventListener('click',function(e){var d=e.target.closest('dialog');if(d&&e.target===d)d.close();});` }} />
93
+ </body>
94
+ </html>
95
+ );
96
+ };
97
+
98
+ const Delta: FC<{ recent: number; prior: number }> = ({ recent, prior }) => {
99
+ if (!prior) return <span class="delta flat">—</span>;
100
+ const change = (recent - prior) / prior;
101
+ const cls = change > 0.05 ? "up" : change < -0.05 ? "down" : "flat";
102
+ const arrow = change > 0.05 ? "▲" : change < -0.05 ? "▼" : "•";
103
+ return <span class={`delta ${cls}`}>{arrow} {pct(Math.abs(change), 0)}</span>;
104
+ };
105
+
106
+ const effortLabel = (e: Action["effort"], long = false) =>
107
+ e === "S" ? (long ? "small effort" : "small") : e === "M" ? (long ? "medium effort" : "medium") : long ? "large effort" : "large";
108
+
109
+ export const ActionModal: FC<{ a: Action; id: string }> = ({ a, id }) => (
110
+ <dialog id={id} class="action-modal">
111
+ <div class="modal-head">
112
+ <div>
113
+ <div class="action-kind">{a.kind} · {a.host}</div>
114
+ <h3>{a.title}</h3>
115
+ </div>
116
+ <form method="dialog"><button class="close" aria-label="Close">✕</button></form>
117
+ </div>
118
+ <div class="modal-chips">
119
+ <span class="chip impact" title="estimated clicks/month — orders the queue, not a forecast">≈ +{a.impact} clicks/mo</span>
120
+ <span class={`chip effort e-${a.effort}`}>{effortLabel(a.effort, true)}</span>
121
+ <span class="chip tag">{a.tag}</span>
122
+ <span class="chip">{a.source === "backlog" ? "curated" : a.source === "proposal" ? "proposed" : "data-derived"}</span>
123
+ </div>
124
+ {a.watching && <p class="action-watching">⏳ {a.watching}</p>}
125
+ <h4>Why (the data)</h4>
126
+ <p>{a.why}</p>
127
+ <h4>The move</h4>
128
+ <p>{a.how}</p>
129
+ <h4>Spec</h4>
130
+ <ul class="spec">
131
+ {a.spec.map((s) => <li>{s}</li>)}
132
+ </ul>
133
+ {a.source === "backlog" ? (
134
+ <div class="modal-actions">
135
+ {!a.watching && (
136
+ <form method="post" action={`/api/backlog/${encodeURIComponent(a.id)}/watch`} class="inline-form">
137
+ <input type="text" name="note" placeholder="what shipped, when — e.g. 'title rewritten 3/4, frozen until 4/1'" />
138
+ <button type="submit" class="btn">Mark watching</button>
139
+ </form>
140
+ )}
141
+ <form method="post" action={`/api/backlog/${encodeURIComponent(a.id)}/retire`} class="inline-form"
142
+ onsubmit="return confirm('Remove this item from config/backlog.json? Prefer Mark watching if the work shipped.')">
143
+ <button type="submit" class="btn btn-quiet">Retire</button>
144
+ </form>
145
+ </div>
146
+ ) : a.source === "proposal" ? (
147
+ <p class="modal-note">Proposed by the opportunity scan. Accept it into your queue with the button on the card, or ignore it — it is replaced on the next scan.</p>
148
+ ) : (
149
+ <p class="modal-note">Data-derived: this card disappears on its own once the numbers move. If the fix shipped, add the page to <code>shippedWatch</code> in <code>config/backlog.json</code> with a dated note and it shows as watching.</p>
150
+ )}
151
+ </dialog>
152
+ );
153
+
154
+ const searchText = (a: Action) => `${a.title} ${a.host} ${a.tag} ${a.kind} ${a.why}`.toLowerCase();
155
+
156
+ export const ActionCard: FC<{ a: Action; rank: number; id: string; showHost?: boolean }> = ({ a, rank, id, showHost }) => (
157
+ <>
158
+ <button class="action" data-search={searchText(a)} onclick={`document.getElementById('${id}').showModal()`}>
159
+ <div class="action-head">
160
+ <span class="rank">{rank}</span>
161
+ <span class="chip impact" title="estimated clicks/month gained — orders the queue, not a forecast">+{a.impact}/mo</span>
162
+ <span class={`chip effort e-${a.effort}`}>{effortLabel(a.effort)}</span>
163
+ </div>
164
+ <div class="action-title">{a.title}</div>
165
+ <div class="action-kind">{a.kind}</div>
166
+ <div class="action-why">{a.why}</div>
167
+ <div class="action-foot">
168
+ {showHost && <span class="action-host">{a.host}</span>}
169
+ <span class="chip tag">{a.tag}</span>
170
+ <span class="spacer" />
171
+ <span class="details-link">details</span>
172
+ </div>
173
+ </button>
174
+ <ActionModal a={a} id={id} />
175
+ </>
176
+ );
177
+
178
+ export const WatchRow: FC<{ a: Action; id: string }> = ({ a, id }) => (
179
+ <>
180
+ <button class="watch-row" data-search={searchText(a)} onclick={`document.getElementById('${id}').showModal()`}>
181
+ <span class="strip-title">{a.title}</span>
182
+ <span class="action-host">{a.host}</span>
183
+ <span class="watch-note">{a.watching}</span>
184
+ <span class="spacer" />
185
+ <span class="chip tag">{a.tag}</span>
186
+ </button>
187
+ <ActionModal a={a} id={id} />
188
+ </>
189
+ );
190
+
191
+ export const SearchBox: FC = () => (
192
+ <input
193
+ id="action-search"
194
+ class="search-box"
195
+ type="search"
196
+ placeholder="Filter by title, site, tag, kind…"
197
+ oninput="var q=this.value.toLowerCase();document.querySelectorAll('[data-search]').forEach(function(el){el.style.display=el.dataset.search.indexOf(q)>-1?'':'none'});"
198
+ />
199
+ );
200
+
201
+ export const ActionQueue: FC<{ actions: Action[]; showHost?: boolean; idPrefix: string }> = ({ actions, showHost, idPrefix }) => (
202
+ <div class="actions">
203
+ {actions.map((a, i) => (
204
+ <ActionCard a={a} rank={i + 1} id={`${idPrefix}-${i}`} showHost={showHost} />
205
+ ))}
206
+ </div>
207
+ );
208
+
209
+ export const SplitQueue: FC<{ actions: Action[]; idPrefix: string }> = ({ actions, idPrefix }) => {
210
+ const active = actions.filter((a) => !a.watching);
211
+ const watching = actions.filter((a) => a.watching);
212
+ return (
213
+ <>
214
+ <h2 id="active">Active <small>({active.length} — ranked by clicks/month per unit of effort)</small></h2>
215
+ {active.length ? (
216
+ <ActionQueue actions={active} showHost idPrefix={`${idPrefix}-a`} />
217
+ ) : (
218
+ <p class="empty">Nothing active. Either the data has no findings yet (run <code>python3 ops/daily.py</code>) or the queue is clear — add strategic items in <code>config/backlog.json</code>.</p>
219
+ )}
220
+ {watching.length > 0 && (
221
+ <>
222
+ <h2 id="watching">Watching <small>({watching.length} — shipped; the data decides what happens next)</small></h2>
223
+ <div class="action-strip watch-list">
224
+ {watching.map((a, i) => <WatchRow a={a} id={`${idPrefix}-w-${i}`} />)}
225
+ </div>
226
+ </>
227
+ )}
228
+ </>
229
+ );
230
+ };
231
+
232
+ const ProposalCard: FC<{ p: data.ScanOutput["proposals"][number]; index: number }> = ({ p, index }) => {
233
+ const a: Action = { ...p, id: `proposal-${index}`, source: "proposal", effort: (["S", "M", "L"].includes(p.effort) ? p.effort : "M") as Action["effort"], spec: p.spec ?? [] };
234
+ const id = `prop-${index}`;
235
+ return (
236
+ <div class="proposal">
237
+ <ActionCard a={a} rank={index + 1} id={id} showHost />
238
+ <form method="post" action="/api/backlog/accept" class="proposal-accept">
239
+ <input type="hidden" name="index" value={String(index)} />
240
+ <button type="submit" class="btn">Accept into queue</button>
241
+ </form>
242
+ </div>
243
+ );
244
+ };
245
+
246
+ export const ActionsPage: FC<{ flash?: string }> = ({ flash }) => {
247
+ const scan = data.opportunityScan();
248
+ return (
249
+ <>
250
+ <div class="strip-head">
251
+ <h1>Action queue</h1>
252
+ <SearchBox />
253
+ </div>
254
+ <p class="sub">Data-derived rules (90-day window) + your curated queue (<code>config/backlog.json</code>). Click any item for the full spec. Impact numbers rank the queue — they are estimates for ordering, not forecasts.</p>
255
+ {flash && <p class="flash">{flash}</p>}
256
+
257
+ {scan && (scan.proposals.length > 0 || scan.verdicts.length > 0) && (
258
+ <div class="scan-panel" id="proposed">
259
+ <h2>Proposed by the opportunity scan <small>· {scan.generated} — rising queries no queue item covers, turned into candidate moves. Accepting copies a proposal into your queue; nothing self-modifies it.</small></h2>
260
+ {scan.proposals.length > 0 && (
261
+ <div class="actions">
262
+ {scan.proposals.map((pr, i) => <ProposalCard p={pr} index={i} />)}
263
+ </div>
264
+ )}
265
+ {scan.verdicts.length > 0 && (
266
+ <>
267
+ <h3 class="or-h">Scan verdicts on watching items <small>— review triggers, not auto-actions</small></h3>
268
+ <ul class="log-lines">
269
+ {scan.verdicts.map((v) => (
270
+ <li class={v.verdict === "failed" ? "log-alert" : ""}>
271
+ <b>{v.verdict.toUpperCase()}</b> — {v.title}: {v.evidence}
272
+ </li>
273
+ ))}
274
+ </ul>
275
+ </>
276
+ )}
277
+ </div>
278
+ )}
279
+ {scan && !scan.proposals.length && scan.candidates.length > 0 && (
280
+ <p class="sub">The scan found {scan.candidates.length} uncovered rising quer{scan.candidates.length === 1 ? "y" : "ies"} but the LLM module is off, so there are no drafted proposals — see <a href="/insights">Insights</a> for the raw risers, or enable it in <a href="/settings">Settings</a>.</p>
281
+ )}
282
+
283
+ <SplitQueue actions={allActions()} idPrefix="all" />
284
+ </>
285
+ );
286
+ };
287
+
288
+ const DAYS = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
289
+ const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
290
+
291
+ export const Overview: FC = () => {
292
+ const now = new Date();
293
+ const dateLabel = `${DAYS[now.getDay()]}, ${MONTHS[now.getMonth()]} ${now.getDate()}`;
294
+ const sites = SITES();
295
+ const lr = data.lastRun();
296
+ const runFailed = Boolean(lr && lr.failures.trim());
297
+ const probe = data.latestProbe();
298
+ const probed = probe?.sites ?? [];
299
+ const healthy = probed.filter((p) => data.probeHealthy(p)).length;
300
+ const board = data.todayBoard();
301
+ const ship = allActions().filter((a) => !a.watching).slice(0, 3);
302
+ const VERB_CLASS: Record<string, string> = { Fix: "v-fix", Approve: "v-approve", Publish: "v-publish", Comment: "v-comment", Ship: "v-ship" };
303
+ const f = data.funnelSummary();
304
+ return (
305
+ <>
306
+ <div class="ov-hero">
307
+ <div>
308
+ <h1>{dateLabel}</h1>
309
+ <p class="sub">Your moves first, then the sites. Everything below is derived live from the data on disk.</p>
310
+ </div>
311
+ <div class="chip-row ov-status">
312
+ {lr ? (runFailed
313
+ ? <a class="chip bad" href="/logs">daily run FAILED</a>
314
+ : <a class="chip good" href="/logs">daily run OK · {lr.ts.slice(5, 16)}</a>)
315
+ : <a class="chip" href="/logs">no daily run yet</a>}
316
+ {probed.length > 0 && (
317
+ <a class={`chip ${healthy === probed.length ? "good" : "bad"}`} href="/probes">{healthy}/{probed.length} sites healthy</a>
318
+ )}
319
+ </div>
320
+ </div>
321
+
322
+ <section class="wb">
323
+ <div class="wb-head">
324
+ <span class="wb-num">→</span>
325
+ <div>
326
+ <h2>Today</h2>
327
+ <p class="sub">What's waiting on you, grouped by the kind of move. Every row is a link.</p>
328
+ </div>
329
+ </div>
330
+ <div class="today-grid">
331
+ {board.map((g) => (
332
+ <div class={`today-col ${VERB_CLASS[g.verb] ?? ""}`}>
333
+ <div class="today-verb">{g.verb} <span class="today-count">{g.items.length}</span></div>
334
+ <p class="today-blurb">{g.blurb}</p>
335
+ {g.items.slice(0, 3).map((it) => (
336
+ <a class="today-item" href={it.href}>
337
+ <span class="today-title">{it.title}</span>
338
+ {it.chip && <span class="chip">{it.chip}</span>}
339
+ </a>
340
+ ))}
341
+ {g.items.length > 3 && (
342
+ <a class="today-more" href={g.items[0].href.startsWith("/drafts") ? "/content#publish" : g.items[0].href}>
343
+ +{g.items.length - 3} more →
344
+ </a>
345
+ )}
346
+ </div>
347
+ ))}
348
+ <div class="today-col v-ship">
349
+ <div class="today-verb">Ship <span class="today-count">{ship.length}</span></div>
350
+ <p class="today-blurb">top of the action queue by impact per effort</p>
351
+ {ship.map((a) => (
352
+ <a class="today-item" href="/actions">
353
+ <span class="today-title">{a.title}</span>
354
+ <span class="chip impact">+{a.impact}/mo</span>
355
+ </a>
356
+ ))}
357
+ {ship.length === 0 && <p class="today-blurb">queue is empty — run the daily job or add items to config/backlog.json</p>}
358
+ <a class="today-more" href="/actions">full queue →</a>
359
+ </div>
360
+ </div>
361
+ </section>
362
+
363
+ <h2>Sites <small>— click a row for the full picture</small></h2>
364
+ {sites.length ? (
365
+ <div class="action-strip">
366
+ <div class="p-row p-head">
367
+ <span>site</span><span></span><span>sessions · 90d</span><span>28d</span><span>Google clicks · 16mo</span><span>AI</span><span>search</span><span></span>
368
+ </div>
369
+ {sites.map((site) => {
370
+ const mix = data.trafficMix(site);
371
+ const trend = data.sessionTrend(site);
372
+ const gsc = data.gscSummary(site);
373
+ const p = data.probeFor(site);
374
+ const ok = data.probeHealthy(p);
375
+ return (
376
+ <a class="p-row" href={`/site/${site.host}`}>
377
+ <span class="domain">{site.host}</span>
378
+ <span class={`dot ${p ? (ok ? "ok" : "warn") : "none"}`} title={p ? (ok ? "probe healthy" : "probe findings") : "not probed yet"} />
379
+ <span class="p-num">{mix.sessions || site.ga4Property ? num(mix.sessions) : "—"}</span>
380
+ <span>{mix.sessions || site.ga4Property ? <Delta recent={trend.recent} prior={trend.prior} /> : <span class="delta flat">—</span>}</span>
381
+ <span class="p-num">{gsc.impressions || site.gscProperty ? num(gsc.clicks) : "—"}</span>
382
+ <span class="p-num">{mix.sessions ? pct(mix.ai / mix.sessions) : "—"}</span>
383
+ <span class="p-num">{mix.sessions ? pct(mix.search / mix.sessions, 0) : "—"}</span>
384
+ <span class="details-link">→</span>
385
+ </a>
386
+ );
387
+ })}
388
+ </div>
389
+ ) : (
390
+ <p class="empty">No sites configured yet — add them to <code>n-seo.config.json</code> (see <a href="/settings">Settings</a>).</p>
391
+ )}
392
+
393
+ {f.configured && (f.instrumented ? (
394
+ <div class="funnel-panel">
395
+ <div class="strip-head"><h2>Conversions <small>— {f.site}: the goal the traffic serves</small></h2></div>
396
+ <div class="stat-row">
397
+ {f.events.map((ev) => (
398
+ <div class="stat"><b>{(f.total28[ev] ?? 0).toLocaleString()}</b><small>{ev} · 28d</small></div>
399
+ ))}
400
+ <div class="chip-row">
401
+ {Object.entries(f.bySource).sort((a, b) => b[1] - a[1]).map(([src, n]) => (
402
+ <span class="chip ai">{src}: {n}</span>
403
+ ))}
404
+ </div>
405
+ </div>
406
+ </div>
407
+ ) : (
408
+ <p class="funnel-line">
409
+ <b>Conversions</b> — configured for {f.site} ({f.events.join(", ")}) but no events have arrived yet. Register them as GA4 key events; this becomes live numbers once they flow.
410
+ </p>
411
+ ))}
412
+
413
+ <details class="wb-fold">
414
+ <summary>Data freshness <small>— what the daily run last refreshed</small></summary>
415
+ <div class="tbl-wrap">
416
+ <table class="slim">
417
+ {data.dataFreshness().map((fr) => (
418
+ <tr>
419
+ <td>{fr.label}</td>
420
+ <td class="mono">{fr.mtime}</td>
421
+ </tr>
422
+ ))}
423
+ </table>
424
+ </div>
425
+ </details>
426
+ </>
427
+ );
428
+ };
429
+
430
+ const QueryTable: FC<{ rows: data.GscRow[] }> = ({ rows }) => (
431
+ <div class="tbl-wrap">
432
+ <table>
433
+ <thead>
434
+ <tr><th>Query</th><th>Clicks</th><th>Impressions</th><th>CTR</th><th>Pos</th></tr>
435
+ </thead>
436
+ <tbody>
437
+ {rows.map((r) => (
438
+ <tr>
439
+ <td>{r.keys[0]}</td>
440
+ <td>{num(r.clicks)}</td>
441
+ <td>{num(r.impressions)}</td>
442
+ <td>{pct(r.ctr)}</td>
443
+ <td>{r.position.toFixed(1)}</td>
444
+ </tr>
445
+ ))}
446
+ </tbody>
447
+ </table>
448
+ </div>
449
+ );
450
+
451
+ export const SiteDetail: FC<{ site: SiteCfg; days?: number }> = ({ site, days = 90 }) => {
452
+ const mix = data.trafficMix(site);
453
+ const trend = data.sessionTrend(site);
454
+ const gsc = data.gscSummary(site);
455
+ const striking = data.strikingDistance(site).slice(0, 15);
456
+ const gaps = data.ctrGaps(site).slice(0, 12);
457
+ const topQueries = data.queries(site).slice(0, 15);
458
+ const topPages = data.pages(site).slice(0, 12);
459
+ const landing = data.landingPages(site).slice(0, 12);
460
+ const probe = data.probeFor(site);
461
+ const xref = data.crossReferrals(site);
462
+ const siteActions = actionsFor(site);
463
+
464
+ const dates = data.lastNDates(days);
465
+ const ts = data.gscTimeseries(site);
466
+ const ga = data.ga4Timeseries(site);
467
+ const srcMix = data.sourceMixTimeseries(site);
468
+ const clicksByDate = new Map<string, number>();
469
+ for (const r of ts) clicksByDate.set(r.date, (clicksByDate.get(r.date) ?? 0) + r.clicks);
470
+ const sessByDate = new Map<string, number>();
471
+ for (const r of ga) sessByDate.set(r.date, (sessByDate.get(r.date) ?? 0) + r.sessions);
472
+ const hasTrends = ts.length > 0 || ga.length > 0 || srcMix.length > 0;
473
+
474
+ return (
475
+ <>
476
+ <h1>{site.host}</h1>
477
+ <p class="sub">
478
+ {site.hosting && <>hosted on {site.hosting} · </>}
479
+ {site.repo && <>repo <code>{site.repo}</code> · </>}
480
+ {site.gscProperty ? <>Search Console <code>{site.gscProperty}</code></> : "no Search Console property configured"}
481
+ {site.ga4Property ? <> · GA4 <code>{site.ga4Property}</code></> : " · no GA4 property configured"}
482
+ </p>
483
+ <div class="statline">
484
+ <span><b>{num(mix.sessions)}</b> sessions · 90d</span>
485
+ <span>28d <Delta recent={trend.recent} prior={trend.prior} /> ({num(trend.recent)} vs {num(trend.prior)})</span>
486
+ <span title="Google Search only, over the full 16-month retention window"><b>{num(gsc.clicks)}</b> Google clicks / <b>{num(gsc.impressions)}</b> impressions · 16 mo · CTR {pct(gsc.ctr)}</span>
487
+ <span class={mix.ai ? "ai-badge" : ""}>AI referrals: <b>{num(mix.ai)}</b> ({mix.sessions ? pct(mix.ai / mix.sessions) : "—"})</span>
488
+ </div>
489
+
490
+ {probe && (
491
+ <div class="probe-line">
492
+ {([
493
+ ["robots.txt", probe.robots.exists],
494
+ ["sitemap", probe.sitemap.exists],
495
+ ["llms.txt", probe["llms.txt"].exists],
496
+ ["real 404", probe.soft_404.real_404],
497
+ ["JSON-LD", (probe.homepage.jsonld_types?.length ?? 0) > 0],
498
+ ] as [string, boolean | undefined][]).map(([label, ok]) => (
499
+ <span class={`pill ${ok ? "ok" : "bad"}`}>{label}</span>
500
+ ))}
501
+ <span class="pill neutral">{num(probe.homepage.visible_text_bytes ?? 0)}B visible text</span>
502
+ </div>
503
+ )}
504
+
505
+ <div class="strip-head gap">
506
+ <h2>Trends <small>· last {days} days</small></h2>
507
+ <div class="range-picker">
508
+ {TREND_RANGES.map((r) => (
509
+ <a href={`/site/${site.host}/${r}`} class={r === days ? "on" : ""}>{r}d</a>
510
+ ))}
511
+ </div>
512
+ </div>
513
+ {hasTrends ? (
514
+ <div class="trend-grid" style={`--n:${days}`}>
515
+ <div class="tg-label"></div>
516
+ <div class="tg-months">
517
+ {dates.map((d, i) => (
518
+ <span>{i === 0 || d.slice(8) === "01" ? d.slice(5, 7) : ""}</span>
519
+ ))}
520
+ </div>
521
+ {ts.length > 0 && <BandChart dates={dates} values={dates.map((d) => clicksByDate.get(d) ?? 0)} title="Google clicks / day" unit="clicks" />}
522
+ {ga.length > 0 && <BandChart dates={dates} values={dates.map((d) => sessByDate.get(d) ?? 0)} title="GA4 sessions / day" unit="sessions" />}
523
+ {srcMix.length > 0 && <SourceStackChart dates={dates} mix={srcMix} />}
524
+ {srcMix.length > 0 && <AiReferralChart dates={dates} mix={srcMix} />}
525
+ </div>
526
+ ) : (
527
+ <p class="empty">
528
+ {site.ga4Property || site.gscProperty
529
+ ? <>no time series yet — run <code>python3 ingest/pull_timeseries.py</code></>
530
+ : "no Search Console or GA4 property configured for this site"}
531
+ </p>
532
+ )}
533
+
534
+ {siteActions.length > 0 && (
535
+ <>
536
+ <div class="strip-head gap">
537
+ <h2>Do next</h2>
538
+ <a class="strip-more" href="/actions">full queue →</a>
539
+ </div>
540
+ <ActionQueue actions={siteActions.slice(0, 6)} idPrefix="site" />
541
+ </>
542
+ )}
543
+
544
+ <div class="site-cols">
545
+ <div class="site-col-main">
546
+ <h2>Striking distance <small>(pos 5–15, the cheapest wins)</small></h2>
547
+ {striking.length ? <QueryTable rows={striking} /> : <p class="empty">none at threshold</p>}
548
+
549
+ <h2>CTR gaps <small>(ranking well, clicked rarely — title/snippet problems)</small></h2>
550
+ {gaps.length ? <QueryTable rows={gaps} /> : <p class="empty">none at threshold</p>}
551
+
552
+ <h2>Top queries · 16mo</h2>
553
+ {topQueries.length ? <QueryTable rows={topQueries} /> : <p class="empty">no Search Console data yet</p>}
554
+ </div>
555
+
556
+ <div class="site-col-side">
557
+ <h2>AI referral sources <small>· 90d</small></h2>
558
+ {mix.aiSources.length ? (
559
+ <div class="mini-list">
560
+ {mix.aiSources.map((s) => (
561
+ <div class="mini-row"><span class="mono trunc">{s.source}</span><b>{num(s.sessions)}</b></div>
562
+ ))}
563
+ </div>
564
+ ) : <p class="empty">none in the window</p>}
565
+
566
+ {xref.length > 0 && (
567
+ <>
568
+ <h2>From your other sites <small>· 90d</small></h2>
569
+ <div class="mini-list">
570
+ {xref.map((s) => (
571
+ <div class="mini-row"><span class="mono trunc">{s.source}</span><b>{num(s.sessions)}</b></div>
572
+ ))}
573
+ </div>
574
+ </>
575
+ )}
576
+ </div>
577
+ </div>
578
+
579
+ <h2>Top pages (Google clicks)</h2>
580
+ <div class="tbl-wrap"><table>
581
+ <thead><tr><th>Page</th><th>Clicks</th><th>Impressions</th><th>Pos</th></tr></thead>
582
+ <tbody>
583
+ {topPages.map((r) => (
584
+ <tr>
585
+ <td class="mono trunc">{r.keys[0].replace(/^https?:\/\/[^/]+/, "") || "/"}</td>
586
+ <td>{num(r.clicks)}</td>
587
+ <td>{num(r.impressions)}</td>
588
+ <td>{r.position.toFixed(1)}</td>
589
+ </tr>
590
+ ))}
591
+ </tbody>
592
+ </table></div>
593
+
594
+ {landing.length > 0 && (
595
+ <>
596
+ <h2>Landing pages (GA4 · 90d)</h2>
597
+ <div class="tbl-wrap"><table>
598
+ <thead><tr><th>Page</th><th>Sessions</th><th>Engagement</th></tr></thead>
599
+ <tbody>
600
+ {landing.map((l) => (
601
+ <tr>
602
+ <td class="mono trunc">{l.page}</td>
603
+ <td>{num(l.sessions)}</td>
604
+ <td class={l.engagement < 0.2 ? "bad-text" : ""}>{pct(l.engagement, 0)}</td>
605
+ </tr>
606
+ ))}
607
+ </tbody>
608
+ </table></div>
609
+ </>
610
+ )}
611
+ </>
612
+ );
613
+ };
614
+
615
+ export const Probes: FC = () => {
616
+ const probe = data.latestProbe();
617
+ if (!probe) return <><h1>Live-site probe</h1><p class="empty">No probe snapshots yet — run <code>python3 probes/site_probe.py</code></p></>;
618
+ return (
619
+ <>
620
+ <h1>Live-site probe</h1>
621
+ <p class="sub">Snapshot {probe.probed_at} · no-auth checks of what crawlers actually see. Re-run any time: <code>python3 probes/site_probe.py</code></p>
622
+ <div class="tbl-wrap"><table>
623
+ <thead>
624
+ <tr><th>Site</th><th>robots</th><th>AI crawlers</th><th>sitemap</th><th>llms.txt</th><th>404</th><th>JSON-LD</th><th>visible text</th><th>Title</th></tr>
625
+ </thead>
626
+ <tbody>
627
+ {probe.sites.map((s) => (
628
+ <tr>
629
+ <td class="mono">{s.site.replace(/^https?:\/\//, "")}</td>
630
+ <td>{s.robots.exists ? "✓" : "✗"}</td>
631
+ <td class={s.robots.ai_crawlers_blocked?.length ? "bad-text" : ""}>{s.robots.ai_crawlers_blocked?.length ? `blocks ${s.robots.ai_crawlers_blocked.join(", ")}` : "open"}</td>
632
+ <td>{s.sitemap.exists ? `✓ ${s.sitemap.url_count ?? ""}` : "✗"}</td>
633
+ <td>{s["llms.txt"].exists ? "✓" : "✗"}</td>
634
+ <td>{s.soft_404.real_404 ? "✓" : "soft!"}</td>
635
+ <td class="mono">{(s.homepage.jsonld_types ?? []).join(", ") || "—"}</td>
636
+ <td class={(s.homepage.visible_text_bytes ?? 0) < 500 ? "bad-text" : ""}>{num(s.homepage.visible_text_bytes ?? 0)}B</td>
637
+ <td class="trunc">{s.homepage.title ?? ""}</td>
638
+ </tr>
639
+ ))}
640
+ </tbody>
641
+ </table></div>
642
+ </>
643
+ );
644
+ };
645
+
646
+ /** Worst first: a page Google has never fetched needs a different response
647
+ * from one it fetched and declined. */
648
+ const COVERAGE_ORDER = [
649
+ "URL is unknown to Google",
650
+ "Discovered - currently not indexed",
651
+ "Soft 404",
652
+ "Crawled - currently not indexed",
653
+ ];
654
+
655
+ const COVERAGE_HELP: Record<string, string> = {
656
+ "URL is unknown to Google": "Not discovered at all — check the sitemap and internal links.",
657
+ "Discovered - currently not indexed": "Known but never fetched. Request Indexing (manual, in the Search Console UI) moves these.",
658
+ "Soft 404": "Serves 200 but Google reads it as an error or empty page.",
659
+ "Crawled - currently not indexed": "Google fetched it and declined — a content/value judgement.",
660
+ };
661
+
662
+ const STALE_DAYS = 90;
663
+
664
+ /** A verdict is only as current as the crawl behind it. A URL can read as
665
+ * "Soft 404" off a crawl from months ago while serving a perfectly healthy
666
+ * page today — chasing that as a template bug is wasted work. Flag the age
667
+ * so the page can't imply a problem it can't see. */
668
+ function staleVerdict(lastCrawl?: string | null): boolean {
669
+ if (!lastCrawl) return false;
670
+ const days = (Date.now() - new Date(lastCrawl).getTime()) / 86_400_000;
671
+ return days > STALE_DAYS;
672
+ }
673
+
674
+ /** Search Console's sitemap state. Worth showing because the console prints
675
+ * a bare "Couldn't fetch" for a sitemap Google has merely not read yet,
676
+ * which looks like a failure — pending with zero errors is a queue, not a
677
+ * problem. */
678
+ const SitemapLine: FC<{ sitemap?: data.IndexStatus["sites"][string]["sitemap"] }> = ({ sitemap }) => {
679
+ if (!sitemap) return null;
680
+ if (sitemap.submitted === 0) {
681
+ return <p class="idx-sitemap">No sitemap submitted in Search Console.</p>;
682
+ }
683
+ return (
684
+ <>
685
+ {sitemap.entries.map((e) => (
686
+ <p class="idx-sitemap">
687
+ sitemap {e.errors ? <strong>{e.errors} errors</strong> : "0 errors"}
688
+ {e.warnings ? `, ${e.warnings} warnings` : ""} ·{" "}
689
+ {e.lastDownloaded
690
+ ? `last read ${e.lastDownloaded.slice(0, 10)}`
691
+ : e.pending
692
+ ? "queued — Google has not read it yet (this is what the console shows as “Couldn’t fetch”)"
693
+ : "never read"}
694
+ </p>
695
+ ))}
696
+ </>
697
+ );
698
+ };
699
+
700
+ export const IndexingPage: FC = () => {
701
+ const status = data.indexStatus();
702
+ if (!status) {
703
+ return (
704
+ <>
705
+ <h1>Indexing</h1>
706
+ <p class="empty">
707
+ No index snapshot yet — enable the <b>Index coverage sweep</b> module and run <code>python3 ingest/pull_index_status.py</code>
708
+ </p>
709
+ </>
710
+ );
711
+ }
712
+ const hosts = Object.entries(status.sites);
713
+ const totalProblems = hosts.reduce((n, [, s]) => n + s.problems.length, 0);
714
+ const totalNever = hosts.reduce((n, [, s]) => n + s.neverCrawled, 0);
715
+ return (
716
+ <>
717
+ <h1>Indexing</h1>
718
+ <p class="sub">
719
+ Search Console's verdict on every sitemap URL · {totalProblems} not indexed,{" "}
720
+ {totalNever} never crawled · snapshot {status.generated.slice(0, 16).replace("T", " ")}
721
+ </p>
722
+ {hosts.map(([host, s]) => {
723
+ if (s.problems.length === 0) {
724
+ return (
725
+ <section class="idx-host">
726
+ <h2>
727
+ {host} <small>{s.indexed}/{s.checked} indexed — all clear</small>
728
+ </h2>
729
+ </section>
730
+ );
731
+ }
732
+ const groups = [...s.problems].sort(
733
+ (a, b) =>
734
+ (COVERAGE_ORDER.indexOf(a.coverage) + 1 || 99) -
735
+ (COVERAGE_ORDER.indexOf(b.coverage) + 1 || 99) ||
736
+ a.url.localeCompare(b.url),
737
+ );
738
+ let lastCoverage = "";
739
+ return (
740
+ <section class="idx-host">
741
+ <h2>
742
+ {host}{" "}
743
+ <small>
744
+ {s.indexed}/{s.checked} indexed · {s.neverCrawled} never crawled
745
+ </small>
746
+ </h2>
747
+ <SitemapLine sitemap={s.sitemap} />
748
+ <div class="tbl-wrap">
749
+ <table>
750
+ <thead>
751
+ <tr><th>URL</th><th>Last crawled</th></tr>
752
+ </thead>
753
+ <tbody>
754
+ {groups.map((p) => {
755
+ const header = p.coverage !== lastCoverage ? p.coverage : null;
756
+ lastCoverage = p.coverage;
757
+ return (
758
+ <>
759
+ {header && (
760
+ <tr class="idx-group">
761
+ <td colspan={2}>
762
+ <strong>{header}</strong>{" "}
763
+ <span class="idx-help">{COVERAGE_HELP[header] ?? p.detail ?? ""}</span>
764
+ </td>
765
+ </tr>
766
+ )}
767
+ <tr>
768
+ <td class="mono trunc">
769
+ <a href={p.url} target="_blank" rel="noopener noreferrer">
770
+ {p.url.replace(/^https?:\/\/[^/]+/, "") || "/"}
771
+ </a>
772
+ {p.canonicalMismatch && (
773
+ <span class="chip"> canonical → {p.googleCanonical}</span>
774
+ )}
775
+ </td>
776
+ <td class="mono">
777
+ {p.lastCrawl ? p.lastCrawl.slice(0, 10) : "never"}
778
+ {staleVerdict(p.lastCrawl) && (
779
+ <span class="idx-stale" title="Google has not looked since this verdict — recheck before treating it as a live bug">
780
+ {" "}stale
781
+ </span>
782
+ )}
783
+ </td>
784
+ </tr>
785
+ </>
786
+ );
787
+ })}
788
+ </tbody>
789
+ </table>
790
+ </div>
791
+ </section>
792
+ );
793
+ })}
794
+ </>
795
+ );
796
+ };
797
+
798
+ const VERDICT_LABEL: Record<string, string> = {
799
+ opportunity: "Opportunity",
800
+ warning: "Course correction",
801
+ momentum: "Momentum",
802
+ deprioritize: "Deprioritize",
803
+ };
804
+
805
+ export const InsightsPage: FC = () => {
806
+ const t = data.latestTrends();
807
+ const ins = loadInsights();
808
+ const hostOfProp = (prop: string) => prop.replace(/^sc-domain:/, "").replace(/^https?:\/\//, "").replace(/\/$/, "");
809
+ return (
810
+ <>
811
+ <h1>Insights</h1>
812
+ <p class="sub">
813
+ {ins.insights.length
814
+ ? <>Your briefing of {ins.date || "(undated)"} from <code>config/insights.json</code>, then the live trend tables (trailing 84 days vs the 84 before).</>
815
+ : <>Live trend tables — trailing 84 days vs the 84 before, per Search Console property. Add your own narrative cards in <code>config/insights.json</code> and they appear above these.</>}
816
+ </p>
817
+
818
+ {ins.insights.length > 0 && (
819
+ <div class="insights-grid">
820
+ {ins.insights.map((i) => (
821
+ <div class={`insight v-${i.verdict}`}>
822
+ <div class="insight-verdict">{VERDICT_LABEL[i.verdict] ?? i.verdict}</div>
823
+ <h3>{i.title}</h3>
824
+ <p class="insight-move">{i.move}</p>
825
+ <details class="insight-why">
826
+ <summary>Why</summary>
827
+ {i.body.map((p) => <p>{p}</p>)}
828
+ </details>
829
+ </div>
830
+ ))}
831
+ </div>
832
+ )}
833
+
834
+ {t ? (
835
+ <>
836
+ <h2>Rising & falling queries <small>— 84d vs prior 84d · analysis of {t.generated}</small></h2>
837
+ {Object.entries(t.sites).filter(([, d]) => d.rising?.length || d.falling?.length).map(([site, d]) => (
838
+ <details class="trend-tile">
839
+ <summary>
840
+ <span class="domain">{hostOfProp(site)}</span>
841
+ <span class="chip good">{(d.rising ?? []).length} rising</span>
842
+ <span class="chip bad">{(d.falling ?? []).length} falling</span>
843
+ {d.recent_split && (
844
+ <span class="chip" title="clicks on non-branded queries, recent 84d">{Math.round(d.recent_split.generic_clicks).toLocaleString()} generic clicks</span>
845
+ )}
846
+ {d.recent_split && d.prior_split && (
847
+ <span class="chip" title="branded clicks recent vs prior 84d">branded {Math.round(d.prior_split.branded_clicks).toLocaleString()} → {Math.round(d.recent_split.branded_clicks).toLocaleString()}</span>
848
+ )}
849
+ <span class="spacer" />
850
+ <span class="details-link">expand</span>
851
+ </summary>
852
+ <div class="tile-body">
853
+ <div class="tbl-wrap"><table>
854
+ <thead><tr><th>Query</th><th>Prior 84d</th><th>Recent 84d</th><th>Pos</th><th></th></tr></thead>
855
+ <tbody>
856
+ {(d.rising ?? []).slice(0, 10).map((m) => (
857
+ <tr><td>{m.query}</td><td>{m.prior_imps.toLocaleString()}</td><td>{m.recent_imps.toLocaleString()}</td><td>{m.recent_pos}</td><td class="delta up">▲</td></tr>
858
+ ))}
859
+ {(d.falling ?? []).slice(0, 5).map((m) => (
860
+ <tr><td>{m.query}</td><td>{m.prior_imps.toLocaleString()}</td><td>{m.recent_imps.toLocaleString()}</td><td></td><td class="delta down">▼</td></tr>
861
+ ))}
862
+ </tbody>
863
+ </table></div>
864
+ </div>
865
+ </details>
866
+ ))}
867
+
868
+ {t.ai_referrals && Object.keys(t.ai_referrals).length > 0 && (() => {
869
+ const months = [...new Set(Object.values(t.ai_referrals!).flatMap((d) => Object.keys(d.total)))].sort().slice(-6);
870
+ return (
871
+ <details class="trend-tile">
872
+ <summary>
873
+ <span class="domain">AI-referral sessions by month</span>
874
+ <span class="chip">the GEO scoreboard</span>
875
+ <span class="spacer" />
876
+ <span class="details-link">expand</span>
877
+ </summary>
878
+ <div class="tile-body">
879
+ <div class="tbl-wrap"><table>
880
+ <thead><tr><th>Site</th>{months.map((m) => <th>{m.slice(0, 4)}-{m.slice(4)}</th>)}</tr></thead>
881
+ <tbody>
882
+ {Object.entries(t.ai_referrals!).map(([site, d]) => (
883
+ <tr><td class="mono">{site}</td>{months.map((m) => <td>{Math.round(d.ai[m] ?? 0)}</td>)}</tr>
884
+ ))}
885
+ </tbody>
886
+ </table></div>
887
+ </div>
888
+ </details>
889
+ );
890
+ })()}
891
+ </>
892
+ ) : (
893
+ <p class="empty">No trend analysis yet — run <code>python3 ingest/analyze_trends.py</code> (the opportunity-scan module does this daily).</p>
894
+ )}
895
+ </>
896
+ );
897
+ };
898
+
899
+ const channelClass = (ch: string) => "ch-" + ch.toLowerCase().replace(/[^a-z]+/g, "");
900
+
901
+ const Briefing: FC<{ text?: string; labels: string[] }> = ({ text, labels }) => {
902
+ const re = new RegExp(`^(${labels.join("|")}):\\s*(.*)$`);
903
+ return (
904
+ <>
905
+ {(text || "").split("\n").filter((l) => l.trim()).map((line) => {
906
+ const m = line.match(re);
907
+ return m ? <p><span class="brief-label">{m[1]}</span> {m[2]}</p> : <p>{line}</p>;
908
+ })}
909
+ {!text && <p class="empty">No briefing generated (enable the LLM module for briefings) — open the thread directly.</p>}
910
+ </>
911
+ );
912
+ };
913
+
914
+ export const ContentPage: FC = () => {
915
+ const mods = config().modules;
916
+ const hnOn = !!mods.hackerNews?.enabled;
917
+ const rdOn = !!mods.reddit?.enabled;
918
+ const participate = hnOn || rdOn;
919
+ const hn = hnOn ? data.hnDigest() : null;
920
+ const rd = rdOn ? data.redditDigest() : null;
921
+ const hunts = rdOn ? data.redditHunts() : [];
922
+ const ds = data.drafts();
923
+ const cs = data.campaigns();
924
+ const contentActions = allActions().filter((a) => ["content", "distribution", "outreach"].includes(a.tag));
925
+ const hnFresh = (hn?.picks ?? []).filter((p) => !p.commented).length;
926
+ const activeCount = contentActions.filter((a) => !a.watching).length;
927
+ let step = 0;
928
+ const next = () => String(++step);
929
+ const participateNum = participate ? next() : "";
930
+ const publishNum = next();
931
+ const campaignsNum = next();
932
+ return (
933
+ <>
934
+ <h1>Content & distribution</h1>
935
+ <p class="sub">
936
+ Ordered like the work: {participate ? "comment daily, " : ""}publish what's ready, run the campaigns, check the queue. Nothing here posts on your behalf.
937
+ </p>
938
+ <div class="wb-nav">
939
+ {participate && <a href="#participate"><span class="wb-num">{participateNum}</span> Participate <small>{hnOn ? `${hnFresh} HN briefed` : ""}{hnOn && rdOn ? " · " : ""}{rdOn ? `${hunts.length} Reddit hunts` : ""}</small></a>}
940
+ <a href="#publish"><span class="wb-num">{publishNum}</span> Publish <small>{ds.length} draft{ds.length === 1 ? "" : "s"}</small></a>
941
+ <a href="#campaigns"><span class="wb-num">{campaignsNum}</span> Campaigns <small>{cs.length ? `${cs.length} running` : "none"}</small></a>
942
+ <a href="/actions"><span class="wb-num">→</span> Queue <small>{activeCount} content/distribution active</small></a>
943
+ </div>
944
+
945
+ {participate && (
946
+ <section class="wb" id="participate">
947
+ <div class="wb-head">
948
+ <span class="wb-num">{participateNum}</span>
949
+ <div>
950
+ <h2>Participate</h2>
951
+ <p class="sub">A few genuine comments a day where you actually know things — that account history is what makes launches land later. Briefings only; the words are always yours.</p>
952
+ </div>
953
+ </div>
954
+
955
+ {hnOn && (
956
+ <>
957
+ <h3 class="wb-sub">Hacker News <small>{hn ? `· refreshed ${hn.generated.slice(0, 10)}` : ""}{hn?.stats?.user ? ` · ${hn.stats.user}: ${hn.stats.karma ?? "?"} karma, ${hn.stats.comments ?? 0} recent comments` : ""}</small></h3>
958
+ {hn?.picks.length ? (
959
+ <div class="action-strip">
960
+ {hn.picks.map((p) => (
961
+ <details class="hn-item">
962
+ <summary class="strip-row">
963
+ {p.commented && <span class="chip commented" title="you have already commented here">✓</span>}
964
+ <span class="strip-title" style="max-width:55%">{p.title}</span>
965
+ <span class="why">{p.why}</span>
966
+ <span class="spacer" />
967
+ <span class="chip" title="comments in the thread">{p.comments} comments</span>
968
+ <span class="chip" title="HN points">{p.points} pts</span>
969
+ </summary>
970
+ <div class="hn-brief">
971
+ <Briefing text={p.briefing} labels={["GIST", "THREAD", "ANGLE"]} />
972
+ <p><a href={p.url} target="_blank" rel="noopener">Open the HN thread →</a></p>
973
+ <p class="brief-note">Briefing only — the comment is yours to write.</p>
974
+ </div>
975
+ </details>
976
+ ))}
977
+ </div>
978
+ ) : (
979
+ <p class="empty">No fresh threads — the daily run refreshes this list (<code>python3 ops/hn_digest.py</code> any time).</p>
980
+ )}
981
+ </>
982
+ )}
983
+
984
+ {rdOn && (
985
+ <>
986
+ <h3 class="wb-sub">Reddit <small>· each chip opens that sub's past-week search in your browser — link a resource only when it directly answers the question</small></h3>
987
+ {hunts.length ? (
988
+ <div class="hunt-row">
989
+ {hunts.map((h) => (
990
+ <a
991
+ class="hunt-chip"
992
+ target="_blank"
993
+ rel="noopener"
994
+ title={`${h.q}${h.why ? ` — ${h.why}` : ""}`}
995
+ href={`https://www.reddit.com/r/${h.sub}/search/?q=${encodeURIComponent(h.q)}&restrict_sr=1&sort=new&t=week`}
996
+ >
997
+ r/{h.sub} <span class="hunt-go">→</span>
998
+ </a>
999
+ ))}
1000
+ </div>
1001
+ ) : (
1002
+ <p class="empty">Add subreddit topics in <a href="/settings">Settings</a> to get hunt links here.</p>
1003
+ )}
1004
+ {rd?.picks.length ? (
1005
+ <>
1006
+ <h3 class="wb-sub">Auto-found Reddit picks <small>· {rd.generated.slice(0, 10)}</small></h3>
1007
+ <div class="action-strip">
1008
+ {rd.picks.map((p) => (
1009
+ <details class="hn-item">
1010
+ <summary class="strip-row">
1011
+ {p.commented && <span class="chip commented" title="already commented here">✓</span>}
1012
+ <span class="chip src">r/{p.sub}</span>
1013
+ <span class="strip-title" style="max-width:50%">{p.title}</span>
1014
+ <span class="why">{p.why}</span>
1015
+ <span class="spacer" />
1016
+ <span class="chip" title="comments in the thread">{p.comments} comments</span>
1017
+ <span class="chip" title="thread age">{p.age_days}d old</span>
1018
+ </summary>
1019
+ <div class="hn-brief">
1020
+ <Briefing text={p.briefing} labels={["GIST", "ANGLE"]} />
1021
+ <p><a href={p.url} target="_blank" rel="noopener">Open the Reddit thread →</a></p>
1022
+ <p class="brief-note">Briefing only — the comment is yours to write.</p>
1023
+ </div>
1024
+ </details>
1025
+ ))}
1026
+ </div>
1027
+ </>
1028
+ ) : rd && !rd.auth ? (
1029
+ <p class="empty">Reddit digest ran without credentials, so no threads were fetched — add REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET to <code>.env</code>.</p>
1030
+ ) : null}
1031
+ </>
1032
+ )}
1033
+ </section>
1034
+ )}
1035
+
1036
+ <section class="wb" id="publish">
1037
+ <div class="wb-head">
1038
+ <span class="wb-num">{publishNum}</span>
1039
+ <div>
1040
+ <h2>Publish</h2>
1041
+ <p class="sub">Finished assets waiting on your voice pass. Open a card for the instructions and the full text.</p>
1042
+ </div>
1043
+ </div>
1044
+ {ds.length ? (
1045
+ <div class="action-strip">
1046
+ {ds.map((d) => (
1047
+ <a class="strip-row draft-row" href={`/drafts/${d.slug}`}>
1048
+ <span class="wb-num">{d.order}</span>
1049
+ <span class="draft-row-main">
1050
+ <span class="draft-row-title">{d.title}</span>
1051
+ {d.action && <span class="draft-row-action">{d.action}</span>}
1052
+ </span>
1053
+ <span class="spacer" />
1054
+ <span class={`chip src ${channelClass(d.channel)}`}>{d.channel}</span>
1055
+ <span class={`chip ${d.status.startsWith("ready") ? "good" : ""}`}>{d.status}</span>
1056
+ <span class="details-link">open →</span>
1057
+ </a>
1058
+ ))}
1059
+ </div>
1060
+ ) : (
1061
+ <p class="empty">No drafts staged. Add markdown files with front-matter (title, order, action, channel, status) in <code>content/drafts/</code>.</p>
1062
+ )}
1063
+ </section>
1064
+
1065
+ <section class="wb" id="campaigns">
1066
+ <div class="wb-head">
1067
+ <span class="wb-num">{campaignsNum}</span>
1068
+ <div>
1069
+ <h2>Campaigns</h2>
1070
+ <p class="sub">Standing plays with their own targets, templates, and weekly plan. Sends are yours.</p>
1071
+ </div>
1072
+ </div>
1073
+ {cs.length ? (
1074
+ <div class="action-strip">
1075
+ {cs.map((c) => (
1076
+ <a class="today-item campaign-sum" href={`/campaigns/${c.slug}`}>
1077
+ <span class="today-title">{c.name}{c.site ? ` · ${c.site}` : ""} — {c.plan.length} planned sends, {c.templates.length} templates, {c.targets.length} ranked targets</span>
1078
+ <span class="chip good">open the playbook →</span>
1079
+ </a>
1080
+ ))}
1081
+ </div>
1082
+ ) : (
1083
+ <p class="empty">No campaign running. Add a JSON file in <code>content/campaigns/</code> (schema in docs/ARCHITECTURE.md).</p>
1084
+ )}
1085
+ </section>
1086
+ </>
1087
+ );
1088
+ };
1089
+
1090
+ export const DraftPage: FC<{ d: data.Draft }> = ({ d }) => (
1091
+ <>
1092
+ <p class="crumb"><a href="/content#publish">← Content · Publish</a></p>
1093
+ <div class="chip-row">
1094
+ <span class={`chip src ${channelClass(d.channel)}`}>{d.channel}</span>
1095
+ <span class={`chip ${d.status.startsWith("ready") ? "good" : ""}`}>{d.status}</span>
1096
+ {d.tags && <span class="chip tag">{d.tags}</span>}
1097
+ <span class="chip">step {d.order} of the publish order</span>
1098
+ </div>
1099
+ <h1 class="draft-title">{d.title}</h1>
1100
+
1101
+ <div class="draft-todo">
1102
+ <h2>What to do</h2>
1103
+ {d.action && <p class="draft-todo-action">{d.action}</p>}
1104
+ {d.notes && <p class="sub">{d.notes}</p>}
1105
+ </div>
1106
+
1107
+ <div class="draft-page-body">
1108
+ <div class="draft-body-head">
1109
+ <span>The full text <small>· markdown, ready to paste</small></span>
1110
+ <button
1111
+ class="copy-btn"
1112
+ type="button"
1113
+ onclick="var b=document.querySelector('.draft-body');navigator.clipboard.writeText(b.textContent).then(()=>{this.textContent='copied ✓';setTimeout(()=>this.textContent='copy markdown',1500)});"
1114
+ >copy markdown</button>
1115
+ </div>
1116
+ <pre class="email-body draft-body">{d.body}</pre>
1117
+ </div>
1118
+ <p class="sub">Source: content/drafts/{d.slug}.md — edit there; delete the file to retire it.</p>
1119
+ </>
1120
+ );
1121
+
1122
+ export const CampaignPage: FC<{ c: data.Campaign }> = ({ c }) => (
1123
+ <>
1124
+ <p class="crumb"><a href="/content#campaigns">← Content · Campaigns</a></p>
1125
+ <h1>{c.name}</h1>
1126
+ <p class="sub">
1127
+ {c.site && <>{c.site} · </>}{c.targets.length} targets{c.voice ? <> · voice: {c.voice}</> : null}. {c.summary || "Sends are yours; everything is prepared to personalize and go."}
1128
+ </p>
1129
+
1130
+ {c.plan.length > 0 && (
1131
+ <>
1132
+ <h4 class="or-h">This week's sends</h4>
1133
+ <div class="action-strip">
1134
+ {c.plan.map((p) => (
1135
+ <div class="strip-row or-plan">
1136
+ <span class="chip">{p.day}</span>
1137
+ <span class="strip-title" style="max-width:45%">{p.action}</span>
1138
+ {p.template && <span class="chip tag">tmpl {p.template}</span>}
1139
+ <span class="or-note">{p.notes}</span>
1140
+ </div>
1141
+ ))}
1142
+ </div>
1143
+ </>
1144
+ )}
1145
+
1146
+ {c.templates.length > 0 && (
1147
+ <details class="wb-fold">
1148
+ <summary>Templates <small>({c.templates.length})</small></summary>
1149
+ <div class="email-grid">
1150
+ {c.templates.map((t) => (
1151
+ <div class="email-card">
1152
+ <div class="email-head">
1153
+ <div class="email-meta"><span>To:</span> {t.audience}</div>
1154
+ <div class="email-subject">{t.subject}</div>
1155
+ </div>
1156
+ <pre class="email-body">{t.body}</pre>
1157
+ </div>
1158
+ ))}
1159
+ </div>
1160
+ </details>
1161
+ )}
1162
+
1163
+ <details class="wb-fold" open={c.plan.length === 0}>
1164
+ <summary>All targets <small>({c.targets.length}, ranked — hook, contact path, and odds for each)</small></summary>
1165
+ <div class="tbl-wrap"><table class="or-table">
1166
+ <thead><tr><th>#</th><th>Target</th><th>Type</th><th>Status</th><th>How to reach</th><th>The hook</th><th>Value</th><th>Odds</th></tr></thead>
1167
+ <tbody>
1168
+ {c.targets.map((t) => (
1169
+ <tr>
1170
+ <td>{t.rank}</td>
1171
+ <td>{t.url ? <a href={/^https?:/.test(t.url) ? t.url : `https://${t.url.split(" ")[0]}`} target="_blank" rel="noopener">{t.name}</a> : t.name}</td>
1172
+ <td><span class="chip tag">{t.category}</span></td>
1173
+ <td>{t.status ? <span class="chip good">{t.status}</span> : <span class="chip">—</span>}</td>
1174
+ <td class="or-cell">{t.contact}</td>
1175
+ <td class="or-cell">{t.angle}</td>
1176
+ <td>{t.value}</td>
1177
+ <td>{t.likelihood}</td>
1178
+ </tr>
1179
+ ))}
1180
+ </tbody>
1181
+ </table></div>
1182
+ {c.week2 && <p class="sub"><b>Week 2:</b> {c.week2}</p>}
1183
+ {c.later && <p class="sub"><b>Later / long plays:</b> {c.later}</p>}
1184
+ {c.cautions.length > 0 && (
1185
+ <p class="sub"><b>Cautions:</b> {c.cautions.join(" · ")}</p>
1186
+ )}
1187
+ </details>
1188
+ <p class="sub">Source: content/campaigns/{c.slug}.json</p>
1189
+ </>
1190
+ );
1191
+
1192
+ const logLine = (l: string) => {
1193
+ const alert = l.includes("ALERT");
1194
+ const text = l.replace(/^- /, "").replace(/\*\*/g, "");
1195
+ return <li class={alert ? "log-alert" : ""}>{text}</li>;
1196
+ };
1197
+
1198
+ export const LogsPage: FC = () => {
1199
+ const sections = data.dailyLogSections();
1200
+ const lr = data.lastRun();
1201
+ return (
1202
+ <>
1203
+ <h1>Operations log</h1>
1204
+ <p class="sub">What each daily run found, newest first. ALERT lines mean a probe regression or a watched metric moved. Raw run output below.</p>
1205
+
1206
+ {lr?.steps?.length ? (
1207
+ <div class="chip-row" style="margin-bottom:14px">
1208
+ {lr.steps.map((s) => (
1209
+ <span class={`chip ${s.ok ? "good" : "bad"}`} title={s.seconds != null ? `${s.seconds}s` : ""}>{s.ok ? "✓" : "✗"} {s.name}</span>
1210
+ ))}
1211
+ </div>
1212
+ ) : null}
1213
+
1214
+ {sections.length ? sections.map((sec) => (
1215
+ <div class="log-day">
1216
+ <h2>{sec.heading}</h2>
1217
+ <ul class="log-lines">{sec.lines.map(logLine)}</ul>
1218
+ </div>
1219
+ )) : <p class="empty">No daily-log entries yet — <code>python3 ops/daily.py</code> writes <code>docs/daily-log.md</code>.</p>}
1220
+
1221
+ <h2>Raw run output <small>(data/daily-ops.log, last 150 lines — look here if a run failed)</small></h2>
1222
+ <div class="tbl-wrap"><pre class="ops-log">{data.opsLogTail()}</pre></div>
1223
+ </>
1224
+ );
1225
+ };
1226
+
1227
+ /** Unified trends: one window drives charts + heatmap on identical geometry. */
1228
+ export const TREND_RANGES = [30, 60, 90, 120, 180];
1229
+
1230
+ const BandChart: FC<{ dates: string[]; values: number[]; title: string; unit: string; note?: string; tone?: string }> = ({ dates, values, title, unit, note, tone }) => {
1231
+ const N = dates.length, W = N * 10, H = 100;
1232
+ const max = Math.max(1, ...values);
1233
+ const x = (i: number) => i * 10 + 5;
1234
+ const y = (v: number) => 4 + (H - 8) - (v / max) * (H - 8);
1235
+ const line = values.map((v, i) => `${i ? "L" : "M"}${x(i)},${y(v).toFixed(1)}`).join(" ");
1236
+ const area = `${line} L${x(N - 1)},${H - 4} L${x(0)},${H - 4} Z`;
1237
+ const peak = Math.max(...values), last = values[N - 1] ?? 0;
1238
+ return (
1239
+ <>
1240
+ <div class="tg-label">
1241
+ <div class="chart-title">{title}</div>
1242
+ <div class="chart-stats">peak {Math.round(peak).toLocaleString()} · latest <b>{Math.round(last).toLocaleString()}</b></div>
1243
+ {note && <div class="chart-note">{note}</div>}
1244
+ </div>
1245
+ <div class={`tg-plot${tone ? ` tone-${tone}` : ""}`}>
1246
+ <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label={title}>
1247
+ <line x1="0" x2={W} y1={y(max)} y2={y(max)} class="grid" vector-effect="non-scaling-stroke" />
1248
+ <line x1="0" x2={W} y1={y(max / 2)} y2={y(max / 2)} class="grid" vector-effect="non-scaling-stroke" />
1249
+ <line x1="0" x2={W} y1={H - 4} y2={H - 4} class="axis" vector-effect="non-scaling-stroke" />
1250
+ <path d={area} class="area-fill" />
1251
+ <path d={line} class="area-line" vector-effect="non-scaling-stroke" />
1252
+ {values.map((v, i) => (
1253
+ <rect x={i * 10} y="0" width="10" height={H} class="hover-col" data-date={dates[i]}>
1254
+ <title>{dates[i]}: {Math.round(v).toLocaleString()} {unit}</title>
1255
+ </rect>
1256
+ ))}
1257
+ </svg>
1258
+ </div>
1259
+ </>
1260
+ );
1261
+ };
1262
+
1263
+ /** css-safe suffix for a group name, e.g. "AI assistants" -> "ai-assistants" */
1264
+ const groupSlug = (g: string) => g.toLowerCase().replace(/[^a-z0-9]+/g, "-");
1265
+
1266
+ /**
1267
+ * Sessions per traffic group, stacked. Shares BandChart's geometry so the
1268
+ * shared hover highlight lines up with every other row in the grid.
1269
+ */
1270
+ const SourceStackChart: FC<{ dates: string[]; mix: data.SourceMixDay[] }> = ({ dates, mix }) => {
1271
+ const N = dates.length, W = N * 10, H = 100;
1272
+ const byDate = new Map(mix.map((d) => [d.date, d]));
1273
+ const at = (date: string, g: data.SourceGroup) => byDate.get(date)?.groups[g] ?? 0;
1274
+ const totals = dates.map((d) => byDate.get(d)?.total ?? 0);
1275
+ const max = Math.max(1, ...totals);
1276
+ const x = (i: number) => i * 10 + 5;
1277
+ const y = (v: number) => 4 + (H - 8) - (v / max) * (H - 8);
1278
+
1279
+ // Cumulative bands, drawn bottom-up: each group's ribbon runs along its own
1280
+ // top edge and back along the running total beneath it.
1281
+ let below = dates.map(() => 0);
1282
+ const bands = data.SOURCE_GROUPS.map((g) => {
1283
+ const top = dates.map((d, i) => below[i] + at(d, g));
1284
+ const up = top.map((v, i) => `${i ? "L" : "M"}${x(i)},${y(v).toFixed(1)}`).join(" ");
1285
+ const back = below.map((v, i) => `L${x(N - 1 - i)},${y(below[N - 1 - i]).toFixed(1)}`).join(" ");
1286
+ const total = dates.reduce((s, d) => s + at(d, g), 0);
1287
+ below = top;
1288
+ return { group: g, d: `${up} ${back} Z`, total };
1289
+ });
1290
+ const windowTotal = totals.reduce((s, v) => s + v, 0);
1291
+
1292
+ return (
1293
+ <>
1294
+ <div class="tg-label">
1295
+ <div class="chart-title">Sessions by source</div>
1296
+ <div class="chart-stats">{Math.round(windowTotal).toLocaleString()} sessions in window</div>
1297
+ <div class="src-legend">
1298
+ {bands.filter((b) => b.total > 0).map((b) => (
1299
+ <span class="src-key">
1300
+ <i class={`swatch grp-${groupSlug(b.group)}`} />
1301
+ {b.group} <b>{pct(b.total / Math.max(1, windowTotal), 0)}</b>
1302
+ </span>
1303
+ ))}
1304
+ </div>
1305
+ </div>
1306
+ <div class="tg-plot">
1307
+ <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label="Sessions by traffic source">
1308
+ <line x1="0" x2={W} y1={y(max)} y2={y(max)} class="grid" vector-effect="non-scaling-stroke" />
1309
+ <line x1="0" x2={W} y1={y(max / 2)} y2={y(max / 2)} class="grid" vector-effect="non-scaling-stroke" />
1310
+ <line x1="0" x2={W} y1={H - 4} y2={H - 4} class="axis" vector-effect="non-scaling-stroke" />
1311
+ {bands.map((b) => <path d={b.d} class={`stack-band grp-${groupSlug(b.group)}`} />)}
1312
+ {dates.map((d, i) => (
1313
+ <rect x={i * 10} y="0" width="10" height={H} class="hover-col" data-date={d}>
1314
+ <title>{d}: {Math.round(byDate.get(d)?.total ?? 0).toLocaleString()} sessions
1315
+ {data.SOURCE_GROUPS.filter((g) => at(d, g) > 0).map((g) => `\n ${g}: ${Math.round(at(d, g)).toLocaleString()}`).join("")}</title>
1316
+ </rect>
1317
+ ))}
1318
+ </svg>
1319
+ </div>
1320
+ </>
1321
+ );
1322
+ };
1323
+
1324
+ /**
1325
+ * AI assistants on their own axis. In the stack it is a sliver — a couple of
1326
+ * percent of sessions on most sites — so the trend that matters is invisible
1327
+ * there. This is the same series, scaled to itself.
1328
+ */
1329
+ const AiReferralChart: FC<{ dates: string[]; mix: data.SourceMixDay[] }> = ({ dates, mix }) => {
1330
+ const byDate = new Map(mix.map((d) => [d.date, d]));
1331
+ const values = dates.map((d) => byDate.get(d)?.groups["AI assistants"] ?? 0);
1332
+ const share = (() => {
1333
+ const ai = values.reduce((s, v) => s + v, 0);
1334
+ const all = dates.reduce((s, d) => s + (byDate.get(d)?.total ?? 0), 0);
1335
+ return all ? pct(ai / all, 1) : "—";
1336
+ })();
1337
+ return (
1338
+ <BandChart
1339
+ dates={dates}
1340
+ values={values}
1341
+ title="AI assistants / day"
1342
+ unit="sessions"
1343
+ note={`own scale · ${share} of sessions`}
1344
+ tone="ai"
1345
+ />
1346
+ );
1347
+ };
1348
+
1349
+ const heatColor = (v: number, max: number) => {
1350
+ if (v <= 0) return "var(--code-bg)";
1351
+ const t = Math.sqrt(v / max);
1352
+ return `rgba(55, 87, 214, ${(0.16 + 0.84 * t).toFixed(2)})`;
1353
+ };
1354
+
1355
+ export const TrendsPage: FC<{ days: number }> = ({ days }) => {
1356
+ const dates = data.lastNDates(days);
1357
+ const window = new Set(dates);
1358
+ const sites = SITES();
1359
+ return (
1360
+ <>
1361
+ <div class="strip-head">
1362
+ <h1>Trends</h1>
1363
+ <div class="range-picker">
1364
+ {TREND_RANGES.map((r) => (
1365
+ <a href={`/trends/${r}`} class={r === days ? "on" : ""}>{r}d</a>
1366
+ ))}
1367
+ </div>
1368
+ </div>
1369
+ <p class="sub">One window drives everything: daily Google clicks, GA4 sessions, and which pages were hit on which days — hover any day to highlight it across all rows. The window ends {data.lastNDates(1).at(-1)} because Search Console finalizes daily data about three days behind real time (GA4 runs a day behind); the last few days always fill in as Google publishes them.</p>
1370
+ {sites.length === 0 && <p class="empty">No sites configured.</p>}
1371
+ {(() => {
1372
+ // Always-visible portfolio answer. The per-site tiles below are
1373
+ // collapsed, and "where is the traffic coming from" should not need
1374
+ // a click to answer.
1375
+ const mix = data.portfolioSourceMix();
1376
+ if (!mix.length) return null;
1377
+ const withGa4 = sites.filter((s) => data.sourceMixTimeseries(s).length).length;
1378
+ return (
1379
+ <div class="trend-grid trend-portfolio" style={`--n:${days}`}>
1380
+ <div class="tg-label">
1381
+ <div class="chart-title">All sites</div>
1382
+ <div class="chart-stats">{withGa4} of {sites.length} with GA4</div>
1383
+ </div>
1384
+ <div class="tg-months">
1385
+ {dates.map((d, i) => (
1386
+ <span>{i === 0 || d.slice(8) === "01" ? d.slice(5, 7) : ""}</span>
1387
+ ))}
1388
+ </div>
1389
+ <SourceStackChart dates={dates} mix={mix} />
1390
+ <AiReferralChart dates={dates} mix={mix} />
1391
+ </div>
1392
+ );
1393
+ })()}
1394
+ {sites.map((site) => {
1395
+ const ts = data.gscTimeseries(site);
1396
+ const ga = data.ga4Timeseries(site);
1397
+ const srcMix = data.sourceMixTimeseries(site);
1398
+ // A configured site with no data yet gets a flat row rather than
1399
+ // disappearing. Silent omission reads as "the pipeline missed it".
1400
+ if (!ts.length && !ga.length && !srcMix.length) {
1401
+ return (
1402
+ <div class="trend-tile trend-empty">
1403
+ <span class="domain">{site.host}</span>
1404
+ <span class="chip">{site.gscProperty ? "Search Console connected" : "no Search Console property"}</span>
1405
+ <span class="chip">{site.ga4Property ? "GA4 connected" : "no GA4 property"}</span>
1406
+ <span class="trend-empty-note">no time series yet — run <code>python3 ingest/pull_timeseries.py</code></span>
1407
+ </div>
1408
+ );
1409
+ }
1410
+
1411
+ const clicksByDate = new Map<string, number>();
1412
+ for (const r of ts) clicksByDate.set(r.date, (clicksByDate.get(r.date) ?? 0) + r.clicks);
1413
+ const sessByDate = new Map<string, number>();
1414
+ for (const r of ga) sessByDate.set(r.date, (sessByDate.get(r.date) ?? 0) + r.sessions);
1415
+
1416
+ const byPage = new Map<string, { total: number; cells: Map<string, { clicks: number; imps: number }> }>();
1417
+ for (const r of ts) {
1418
+ if (!window.has(r.date)) continue;
1419
+ const cur = byPage.get(r.page) ?? { total: 0, cells: new Map() };
1420
+ cur.total += r.clicks;
1421
+ cur.cells.set(r.date, { clicks: r.clicks, imps: r.impressions });
1422
+ byPage.set(r.page, cur);
1423
+ }
1424
+ const top = [...byPage.entries()].sort((a, b) => b[1].total - a[1].total).slice(0, 10);
1425
+ const cellMax = Math.max(1, ...top.flatMap(([, v]) => [...v.cells.values()].map((c) => c.clicks)));
1426
+
1427
+ const sparkVals = dates.map((d) => (ts.length ? clicksByDate.get(d) ?? 0 : sessByDate.get(d) ?? 0));
1428
+ const sparkMax = Math.max(1, ...sparkVals);
1429
+ const sparkPts = sparkVals.map((v, i) => `${(i / Math.max(1, sparkVals.length - 1)) * 120},${26 - (v / sparkMax) * 24}`).join(" ");
1430
+ const last7 = dates.slice(-7);
1431
+ const avg = (m: Map<string, number>) => last7.reduce((a, d) => a + (m.get(d) ?? 0), 0) / Math.max(1, last7.length);
1432
+ const avgClicks = avg(clicksByDate);
1433
+ const avgSess = avg(sessByDate);
1434
+ const pagePath = (p: string) => p.replace(/^https?:\/\/[^/]+/, "") || "/";
1435
+ return (
1436
+ <details class="trend-tile">
1437
+ <summary>
1438
+ <span class="domain">{site.host}</span>
1439
+ <svg class="trend-spark" viewBox="0 0 120 28" preserveAspectRatio="none" aria-hidden="true">
1440
+ <polyline points={sparkPts} />
1441
+ </svg>
1442
+ <span class="chip" title="average over the last 7 window days">{ts.length ? `~${num(avgClicks)} clicks/day` : "no Search Console yet"}</span>
1443
+ <span class="chip">{ga.length ? `~${num(avgSess)} sessions/day` : "no GA4 yet"}</span>
1444
+ <span class="spacer" />
1445
+ <span class="details-link">expand</span>
1446
+ </summary>
1447
+ <div class="trend-grid" style={`--n:${days}`}>
1448
+ <div class="tg-label"></div>
1449
+ <div class="tg-months">
1450
+ {dates.map((d, i) => (
1451
+ <span>{i === 0 || d.slice(8) === "01" ? d.slice(5, 7) : ""}</span>
1452
+ ))}
1453
+ </div>
1454
+ {ts.length > 0 && <BandChart dates={dates} values={dates.map((d) => clicksByDate.get(d) ?? 0)} title="Google clicks / day" unit="clicks" />}
1455
+ {ga.length > 0 && <BandChart dates={dates} values={dates.map((d) => sessByDate.get(d) ?? 0)} title="GA4 sessions / day" unit="sessions" />}
1456
+ {srcMix.length > 0 && <SourceStackChart dates={dates} mix={srcMix} />}
1457
+ {srcMix.length > 0 && <AiReferralChart dates={dates} mix={srcMix} />}
1458
+ {top.map(([page, v]) => (
1459
+ <>
1460
+ <div class="tg-label hm-label" title={page}>{pagePath(page)}</div>
1461
+ <div class="tg-cells">
1462
+ {dates.map((d) => {
1463
+ const c = v.cells.get(d);
1464
+ return (
1465
+ <div class="hm-cell" data-date={d} style={`background:${heatColor(c?.clicks ?? 0, cellMax)}`}>
1466
+ <span class="hm-tip">{pagePath(page)} — {d}: {c?.clicks ?? 0} clicks, {c?.imps ?? 0} impressions</span>
1467
+ </div>
1468
+ );
1469
+ })}
1470
+ </div>
1471
+ </>
1472
+ ))}
1473
+ </div>
1474
+ </details>
1475
+ );
1476
+ })}
1477
+ <script dangerouslySetInnerHTML={{ __html: `
1478
+ document.addEventListener('mouseover', function(e) {
1479
+ var t = e.target.closest('[data-date]');
1480
+ document.querySelectorAll('.day-hi').forEach(function(el){ el.classList.remove('day-hi'); });
1481
+ if (!t) return;
1482
+ var d = t.dataset.date;
1483
+ document.querySelectorAll('[data-date="' + d + '"]').forEach(function(el){ el.classList.add('day-hi'); });
1484
+ });` }} />
1485
+ </>
1486
+ );
1487
+ };