social-autoposter 1.3.9 → 1.4.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 (74) hide show
  1. package/bin/server.js +1391 -146
  2. package/package.json +1 -1
  3. package/scripts/_insert_post_023.py +127 -0
  4. package/scripts/_scan_aggregate.py +108 -0
  5. package/scripts/_scan_timeline.py +85 -0
  6. package/scripts/_test_since_hook.py +117 -0
  7. package/scripts/audience_pages.py +243 -0
  8. package/scripts/classify_run_error.py +145 -0
  9. package/scripts/dm_db_update.py +69 -0
  10. package/scripts/dm_outreach_twitter_helper.py +129 -0
  11. package/scripts/engage_github.py +6 -5
  12. package/scripts/engage_reddit.py +44 -6
  13. package/scripts/engage_twitter_helper.py +197 -0
  14. package/scripts/engagement_styles.py +119 -18
  15. package/scripts/fetch_twitter_t1.py +41 -29
  16. package/scripts/github_tools.py +139 -10
  17. package/scripts/ig_post_type_picker.py +113 -37
  18. package/scripts/insert_post029.py +86 -0
  19. package/scripts/insert_post_024.py +110 -0
  20. package/scripts/insert_post_026.py +103 -0
  21. package/scripts/insert_post_039.py +80 -0
  22. package/scripts/install_lane_digest.py +5 -1
  23. package/scripts/install_lane_monitor.py +1 -1
  24. package/scripts/log_run.py +47 -6
  25. package/scripts/log_twitter_search_attempts.py +21 -12
  26. package/scripts/log_twitter_skips.py +30 -55
  27. package/scripts/octolens_twitter_batch.py +34 -33
  28. package/scripts/octolens_twitter_cdp.py +29 -29
  29. package/scripts/pick_ig_account.py +192 -0
  30. package/scripts/pick_project.py +108 -43
  31. package/scripts/pick_twitter_thread_target.py +37 -80
  32. package/scripts/post_github.py +185 -68
  33. package/scripts/post_reddit.py +136 -35
  34. package/scripts/precompute_dashboard_stats.py +13 -1
  35. package/scripts/project_stats_json.py +23 -0
  36. package/scripts/reconcile_twitter_search_topic.py +125 -0
  37. package/scripts/reddit_browser.py +60 -4
  38. package/scripts/reddit_tools.py +12 -0
  39. package/scripts/regenerate_ig_plists.py +262 -0
  40. package/scripts/scan_twitter_mentions_browser.py +150 -63
  41. package/scripts/scan_twitter_thread_followups.py +78 -52
  42. package/scripts/score_twitter_candidates.py +102 -103
  43. package/scripts/scrape_reddit_views.py +66 -2
  44. package/scripts/snapshot_style_targets.py +85 -0
  45. package/scripts/strike_alert.py +479 -18
  46. package/scripts/top_dud_twitter_queries.py +20 -34
  47. package/scripts/top_performers.py +223 -14
  48. package/scripts/top_twitter_queries.py +30 -124
  49. package/scripts/twitter_batch_phase.py +26 -44
  50. package/scripts/twitter_browser.py +31 -20
  51. package/scripts/twitter_cycle_helper.py +268 -0
  52. package/scripts/twitter_gen_links.py +35 -2
  53. package/scripts/twitter_post_plan.py +67 -40
  54. package/scripts/twitter_supply_signal.py +8 -53
  55. package/scripts/twitter_threads_helper.py +152 -0
  56. package/scripts/update_stats.py +218 -24
  57. package/skill/dm-outreach-linkedin.sh +1 -1
  58. package/skill/dm-outreach-reddit.sh +1 -1
  59. package/skill/dm-outreach-twitter.sh +23 -49
  60. package/skill/engage-dm-replies.sh +1 -1
  61. package/skill/engage-linkedin.sh +2 -2
  62. package/skill/engage-reddit.sh +15 -0
  63. package/skill/engage-twitter.sh +37 -63
  64. package/skill/lib/twitter-backend.sh +14 -2
  65. package/skill/link-edit-github.sh +1 -1
  66. package/skill/link-edit-reddit.sh +1 -1
  67. package/skill/run-github.sh +13 -1
  68. package/skill/run-instagram-daily.sh +24 -12
  69. package/skill/run-instagram-render.sh +332 -51
  70. package/skill/run-linkedin.sh +14 -3
  71. package/skill/run-reddit-search.sh +13 -0
  72. package/skill/run-twitter-cycle.sh +351 -221
  73. package/skill/run-twitter-threads.sh +19 -27
  74. package/skill/stats.sh +38 -3
package/bin/server.js CHANGED
@@ -477,7 +477,11 @@ const RUN_MONITOR_PATH = path.join(LOG_DIR, 'run_monitor.log');
477
477
  // queries+candidates+above_floor only. Each sub-key is omitted when zero, so
478
478
  // `discover=` itself is absent on lines from pipelines that don't emit it.
479
479
  // Old log lines without the segment still parse cleanly via the optional `?`.
480
- const RUN_LINE_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s*\|\s*(\S+)\s*\|\s*posted=(\d+)\s+skipped=(\d+)\s+failed=(\d+)(?:\s+replies_refreshed=(\d+))?(?:\s+checked=(\d+)\s+updated=(\d+)\s+removed=(\d+))?(?:\s+unavailable=(\d+))?(?:\s+not_found=(\d+))?(?:\s+salvaged=(\d+))?(?:\s+discover=([^\s|]+))?(?:\s+scan=([^\s|]+))?\s+cost=\$([\d.]+)\s+elapsed=(\d+)s(?:\s+failure_reasons=([^\s|]+))?(?:\s+skip_reasons=([^\s|]+))?/;
480
+ // 2026-05-18 stats-pill relabel: three new optional groups (scanned,
481
+ // changed, views_refreshed) tail the unavailable/not_found block so old log
482
+ // lines still parse via the existing positional regex. Each is independently
483
+ // optional so a partial roll-out (just scanned, just changed) also parses.
484
+ const RUN_LINE_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s*\|\s*(\S+)\s*\|\s*posted=(\d+)\s+skipped=(\d+)\s+failed=(\d+)(?:\s+replies_refreshed=(\d+))?(?:\s+checked=(\d+)\s+updated=(\d+)\s+removed=(\d+))?(?:\s+unavailable=(\d+))?(?:\s+not_found=(\d+))?(?:\s+scanned=(\d+))?(?:\s+changed=(\d+))?(?:\s+views_refreshed=(\d+))?(?:\s+salvaged=(\d+))?(?:\s+discover=([^\s|]+))?(?:\s+scan=([^\s|]+))?\s+cost=\$([\d.]+)\s+elapsed=(\d+)s(?:\s+failure_reasons=([^\s|]+))?(?:\s+skip_reasons=([^\s|]+))?/;
481
485
 
482
486
  // posts.platform is lowercase; UI labels are capitalized.
483
487
  const PLATFORM_LABELS = {
@@ -586,7 +590,7 @@ function parseRunMonitorLog(maxLines) {
586
590
  for (const line of tail) {
587
591
  const m = line.match(RUN_LINE_RE);
588
592
  if (!m) continue;
589
- const [, ts, script, posted, skipped, failed, repliesRefreshed, checked, updated, removed, unavailable, notFound, salvaged, discoverStr, scanStr, cost, elapsed, failureReasonsStr, skipReasonsStr] = m;
593
+ const [, ts, script, posted, skipped, failed, repliesRefreshed, checked, updated, removed, unavailable, notFound, scannedRaw, changedRaw, viewsRefreshedRaw, salvaged, discoverStr, scanStr, cost, elapsed, failureReasonsStr, skipReasonsStr] = m;
590
594
  if (JOB_HISTORY_HIDDEN_SCRIPTS.has(script)) continue;
591
595
  // log_run.py writes naive local-wallclock time (strftime without tz), so
592
596
  // `new Date(ts)` in node interprets it as local on the server. That is
@@ -670,6 +674,13 @@ function parseRunMonitorLog(maxLines) {
670
674
  removed: removed ? parseInt(removed, 10) : 0,
671
675
  unavailable: unavailable ? parseInt(unavailable, 10) : 0,
672
676
  not_found: notFound ? parseInt(notFound, 10) : 0,
677
+ // 2026-05-18 stats-pill relabel: three new fields surface the split
678
+ // between Step 1 view-scrape leg and Step 2 metric-changed leg.
679
+ // Absent on pre-relabel log lines (defaults to 0); renderResultPills
680
+ // falls back to `checked`/`updated` when these are 0.
681
+ scanned: scannedRaw ? parseInt(scannedRaw, 10) : 0,
682
+ changed: changedRaw ? parseInt(changedRaw, 10) : 0,
683
+ views_refreshed: viewsRefreshedRaw ? parseInt(viewsRefreshedRaw, 10) : 0,
673
684
  salvaged: salvaged ? parseInt(salvaged, 10) : 0,
674
685
  discover, // {} when no `discover=` segment was present on the line
675
686
  scan, // {} when no `scan=` segment was present on the line
@@ -1130,7 +1141,8 @@ async function enrichPostCommentsTwitterRuns(runs) {
1130
1141
  // otherwise undercount the per-run queue snapshot at older runs.
1131
1142
  const candidateRows = await pq(
1132
1143
  "SELECT discovered_at, posted_at, t1_checked_at, drafted_at, " +
1133
- " (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id " +
1144
+ " (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id, " +
1145
+ " matched_project " +
1134
1146
  "FROM twitter_candidates " +
1135
1147
  "WHERE discovered_at >= $1::timestamp OR posted_at >= $1::timestamp OR t1_checked_at >= $1::timestamp OR status='pending'",
1136
1148
  [since]
@@ -1194,6 +1206,7 @@ async function enrichPostCommentsTwitterRuns(runs) {
1194
1206
  exitMs,
1195
1207
  status: r.status,
1196
1208
  batch_id: r.batch_id || '',
1209
+ matched_project: r.matched_project || '',
1197
1210
  };
1198
1211
  });
1199
1212
 
@@ -1235,9 +1248,25 @@ async function enrichPostCommentsTwitterRuns(runs) {
1235
1248
  }
1236
1249
  let candidatesPassed = 0;
1237
1250
  let salvagePosted = 0;
1251
+ // Project labels this cycle actually worked on, surfaced at the end of the
1252
+ // pill row (mirrors enrichPostCommentsRedditRuns). Source is the
1253
+ // twitter_candidates.matched_project values for rows tied to this run's
1254
+ // own batch_id — i.e. tweets Phase 1 scraped + scored on behalf of these
1255
+ // projects. Insertion-order Set preserves first-seen order while deduping.
1256
+ const projectsSeen = new Set();
1257
+ const projectsList = [];
1258
+ const recordProject = (raw) => {
1259
+ if (!raw) return;
1260
+ const proj = raw.trim();
1261
+ if (proj && !projectsSeen.has(proj)) {
1262
+ projectsSeen.add(proj);
1263
+ projectsList.push(proj);
1264
+ }
1265
+ };
1238
1266
  for (const c of candNorm) {
1239
1267
  if (!ownBatchId || c.batch_id !== ownBatchId) continue;
1240
1268
  candidatesPassed++;
1269
+ recordProject(c.matched_project);
1241
1270
  if (c.status === 'posted') {
1242
1271
  posted++;
1243
1272
  // Salvage signature: candidate's discovered_at predates this cycle's
@@ -1270,6 +1299,19 @@ async function enrichPostCommentsTwitterRuns(runs) {
1270
1299
  const body = fs.readFileSync(path.join(LOG_DIR, chosenLog), 'utf8');
1271
1300
  const m = body.match(phase0SalvageRe);
1272
1301
  if (m) salvageAttempted = parseInt(m[1], 10);
1302
+ // Fallback for scan-only cycles where 0 candidates upserted (so
1303
+ // matched_project never landed in twitter_candidates for this batch):
1304
+ // pull project names from the cycle log's "Selected projects:" header.
1305
+ // This surfaces the projects the cycle SCANNED for, mirroring Reddit's
1306
+ // behaviour of capturing project labels even when nothing posted.
1307
+ if (!projectsList.length) {
1308
+ const selM = body.match(/Selected projects:\s*([^\n]+)/);
1309
+ if (selM) {
1310
+ for (const tok of selM[1].split(',')) {
1311
+ recordProject(tok);
1312
+ }
1313
+ }
1314
+ }
1273
1315
  } catch { /* empty */ }
1274
1316
  }
1275
1317
  // Per-run queue delta. ADD = candidates whose discovered_at fell in this
@@ -1373,6 +1415,12 @@ async function enrichPostCommentsTwitterRuns(runs) {
1373
1415
  salvage_attempted: salvageAttempted,
1374
1416
  salvage_posted: salvagePosted,
1375
1417
  own_batch_id: ownBatchId,
1418
+ // Project(s) this cycle actually worked on (Phase 1 scraped + scored
1419
+ // candidates for), parsed from twitter_candidates.matched_project for
1420
+ // rows tied to this run's batch_id. Surfaces at the end of the dashboard
1421
+ // pill row so the operator can see at a glance which projects consumed
1422
+ // the cycle, even when posted=0. Mirrors enrichPostCommentsRedditRuns.
1423
+ projects_worked: projectsList,
1376
1424
  cost_usd: prior.cost_usd || 0,
1377
1425
  failed: prior.failed || 0,
1378
1426
  failure_reasons: Array.isArray(prior.failure_reasons) ? prior.failure_reasons : [],
@@ -2224,7 +2272,7 @@ async function enrichSeoRuns(runs) {
2224
2272
  }
2225
2273
  if (sessionIds.length) {
2226
2274
  const rows = await pq(
2227
- 'SELECT session_id, total_cost_usd, orchestrator_cost_usd FROM claude_sessions WHERE session_id = ANY($1::uuid[])',
2275
+ 'SELECT session_id, total_cost_usd, orchestrator_cost_usd, subagent_cost_usd FROM claude_sessions WHERE session_id = ANY($1::uuid[])',
2228
2276
  [sessionIds]
2229
2277
  );
2230
2278
  if (rows && rows.length) {
@@ -2234,6 +2282,9 @@ async function enrichSeoRuns(runs) {
2234
2282
  orchestrator: r.orchestrator_cost_usd != null
2235
2283
  ? Number(r.orchestrator_cost_usd)
2236
2284
  : null,
2285
+ subagent: r.subagent_cost_usd != null
2286
+ ? Number(r.subagent_cost_usd)
2287
+ : null,
2237
2288
  }])
2238
2289
  );
2239
2290
  for (const run of seoRuns) {
@@ -2246,16 +2297,20 @@ async function enrichSeoRuns(runs) {
2246
2297
  // sessions, locked callers that don't pass --orchestrator-cost-usd),
2247
2298
  // keep whatever streamRes value _collectSeoDetails parsed from the
2248
2299
  // .log file.
2300
+ // SDK-only: d.cost_usd is orchestrator_cost_usd alone. Estimate
2301
+ // and subagent are kept on the row for diagnostic tooltips but
2302
+ // not folded into the displayed total.
2249
2303
  if (Number.isFinite(row.orchestrator)) {
2250
2304
  d.cost_usd = row.orchestrator;
2251
2305
  d.cost_usd_orchestrator = row.orchestrator;
2252
- } else if (Number.isFinite(d.cost_usd)) {
2253
- d.cost_usd_orchestrator = d.cost_usd;
2306
+ } else {
2307
+ d.cost_usd = null;
2308
+ d.cost_usd_orchestrator = null;
2254
2309
  }
2255
- // Manual estimate alongside (always populated by log_claude_session.py).
2256
2310
  if (Number.isFinite(row.estimated)) {
2257
2311
  d.cost_usd_estimated = row.estimated;
2258
2312
  }
2313
+ d.cost_usd_subagent = Number.isFinite(row.subagent) ? row.subagent : 0;
2259
2314
  }
2260
2315
  }
2261
2316
  }
@@ -2284,6 +2339,229 @@ async function enrichSeoRuns(runs) {
2284
2339
  }
2285
2340
  }
2286
2341
 
2342
+ // Maps a Job History row's `script` (log_run.py canonical name) to the
2343
+ // `claude_sessions.script` values its wrapper actually spawns. Without this
2344
+ // constraint a 40-min post_reddit run's window catches every concurrent
2345
+ // pipeline's sessions (engage-dm-replies, seo_generate_page, etc.) and the
2346
+ // "cost" cell sums everything that ran in parallel — meaningless. Lookup is
2347
+ // keyed on the underscore form (post_reddit, dm_replies_reddit) that survives
2348
+ // classifyScript's normalization. Add new entries here when a new wrapper
2349
+ // pipeline ships.
2350
+ const _PHASE_FAMILY = {
2351
+ // Reddit
2352
+ post_reddit: ['post_reddit'],
2353
+ engage_reddit: ['engage_reddit'],
2354
+ thread_reddit: ['run-reddit-threads'],
2355
+ dm_outreach_reddit: ['dm-outreach-reddit'],
2356
+ dm_replies_reddit: ['engage-dm-replies'],
2357
+ link_edit_reddit: ['link-edit-reddit'],
2358
+ // Twitter
2359
+ post_twitter: ['run-twitter-cycle-scan', 'run-twitter-cycle-prep'],
2360
+ engage_twitter: ['engage-twitter-phaseB'],
2361
+ thread_twitter: ['run-twitter-threads'],
2362
+ dm_outreach_twitter: ['dm-outreach-twitter'],
2363
+ dm_replies_twitter: ['engage-dm-replies'],
2364
+ link_edit_twitter: ['link-edit-twitter'],
2365
+ // LinkedIn (run-linkedin bare = pre-phase-split legacy tag, kept for old rows)
2366
+ post_linkedin: ['run-linkedin-phaseA', 'run-linkedin-phaseB', 'run-linkedin'],
2367
+ engage_linkedin: ['engage-linkedin-phaseA', 'engage-linkedin-phaseB'],
2368
+ dm_replies_linkedin: ['engage-dm-replies'],
2369
+ dm_outreach_linkedin: ['dm-outreach-linkedin'],
2370
+ link_edit_linkedin: ['link-edit-linkedin'],
2371
+ // GitHub
2372
+ post_github: ['post_github'],
2373
+ engage_github: ['engage_github', 'github-engage', 'run-github-cycle'],
2374
+ link_edit_github: ['link-edit-github'],
2375
+ // Moltbook
2376
+ post_moltbook: ['run-moltbook-cycle'],
2377
+ // SEO
2378
+ gsc_seo: ['seo_generate_page', 'seo_generate_page_retry'],
2379
+ serp_seo: ['seo_generate_page', 'seo_generate_page_retry'],
2380
+ seo_improve: ['seo_improve_page'],
2381
+ seo_weekly_roundup: ['seo_generate_page'],
2382
+ seo_top_pages: ['seo_generate_page'],
2383
+ seo_top_posts: ['seo_generate_page'],
2384
+ };
2385
+
2386
+ function _phaseFamilyFor(runScript) {
2387
+ if (!runScript) return null;
2388
+ const norm = String(runScript).replace(/-/g, '_').toLowerCase();
2389
+ const aliased = SCRIPT_ALIASES[norm] || norm;
2390
+ const family = _PHASE_FAMILY[aliased];
2391
+ return family && family.length ? new Set(family) : null;
2392
+ }
2393
+
2394
+ // Per-phase Claude cost breakdown for Job History rows. For each completed
2395
+ // run, queries `claude_sessions` rows whose `script` belongs to the run's
2396
+ // known phase family (see _PHASE_FAMILY) AND whose `started_at` falls inside
2397
+ // [run.started_at - slack, run.finished_at + slack]. Groups results by
2398
+ // session script (the phase) and attaches:
2399
+ // run.result.cost_breakdown = {
2400
+ // total, orchestrator, subagent, estimated,
2401
+ // phases: [{phase, sessions, total, orch, sub, est}, ...] // desc by total
2402
+ // }
2403
+ // run.result.cost_usd = total (overrides the shell-log value when
2404
+ // we found ≥1 matching session — the wrapper
2405
+ // log line often misses sub-phase cost,
2406
+ // e.g. engage_twitter logs $0 but phaseB
2407
+ // spent real money).
2408
+ // run.result.cost_usd_from_log = original shell-log value (preserved for
2409
+ // audit/provenance).
2410
+ // run.result.cost_usd_* = orch/subagent/estimated lanes for the
2411
+ // 4-lane tooltip the Cost column renders.
2412
+ // Runs whose `script` isn't in _PHASE_FAMILY get NO breakdown (we don't know
2413
+ // which sessions to attribute) and keep their shell-log cost.
2414
+ //
2415
+ // cycle_id disambiguation (2026-05-15): time-window + family matching alone
2416
+ // mis-attributes overlapping cycles. Twitter cycles fire every 15 min, so a
2417
+ // 30-min run's window catches the previous cycle's late-running prep and the
2418
+ // next cycle's scan. Each wrapper invocation exports a unique SA_CYCLE_ID
2419
+ // that every child claude_sessions row inherits, so after window+family
2420
+ // matching we pick the ONE cycle_id whose earliest session is closest to the
2421
+ // run's started_at (that's the run's own scan/first phase) and drop every
2422
+ // session belonging to a different cycle_id. Sessions with NULL cycle_id are
2423
+ // only used when the run matched no cycle_id at all (pure fallback).
2424
+ async function enrichRunsCostBreakdown(runs) {
2425
+ const candidates = (runs || []).filter(r =>
2426
+ !r.running && r.started_at && r.finished_at && _phaseFamilyFor(r.script)
2427
+ );
2428
+ if (!candidates.length) return;
2429
+ let minStart = Infinity, maxEnd = 0;
2430
+ for (const r of candidates) {
2431
+ const s = Date.parse(r.started_at);
2432
+ const e = Date.parse(r.finished_at);
2433
+ if (Number.isFinite(s) && s < minStart) minStart = s;
2434
+ if (Number.isFinite(e) && e > maxEnd) maxEnd = e;
2435
+ }
2436
+ if (minStart === Infinity) return;
2437
+ const slackMs = _RUN_WINDOW_SLACK_MS;
2438
+ const since = new Date(minStart - slackMs).toISOString();
2439
+ const until = new Date(maxEnd + slackMs).toISOString();
2440
+ let rows;
2441
+ try {
2442
+ rows = await pq(
2443
+ // SDK-only mode: orchestrator_cost_usd is the only number we display.
2444
+ // Subagent and transcript-estimate are kept for diagnostic tooltips but
2445
+ // never added into the total. has_sdk lets the UI distinguish "session
2446
+ // happened but SDK didn't capture cost" from "session cost was $0".
2447
+ "SELECT script, started_at, cycle_id, " +
2448
+ "orchestrator_cost_usd IS NOT NULL AS has_sdk, " +
2449
+ "COALESCE(orchestrator_cost_usd, 0)::float8 AS orch, " +
2450
+ "COALESCE(total_cost_usd, 0)::float8 AS est, " +
2451
+ "COALESCE(subagent_cost_usd, 0)::float8 AS sub " +
2452
+ "FROM claude_sessions WHERE started_at BETWEEN $1::timestamp AND $2::timestamp",
2453
+ [since, until]
2454
+ );
2455
+ } catch (e) {
2456
+ console.error('[enrichRunsCostBreakdown] query failed:', e && e.message || e);
2457
+ return;
2458
+ }
2459
+ if (!rows || !rows.length) return;
2460
+ const sessionList = rows.map(r => ({
2461
+ ts: r.started_at instanceof Date ? r.started_at.getTime() : Date.parse(r.started_at),
2462
+ script: r.script || '(unknown)',
2463
+ cycleId: r.cycle_id || null,
2464
+ hasSdk: !!r.has_sdk,
2465
+ orch: Number(r.orch) || 0,
2466
+ est: Number(r.est) || 0,
2467
+ sub: Number(r.sub) || 0,
2468
+ // SDK-only: total = orch. Sessions without SDK contribute 0 to total
2469
+ // (they're flagged via hasSdk so the tooltip can show coverage).
2470
+ total: Number(r.orch) || 0,
2471
+ }));
2472
+ for (const r of candidates) {
2473
+ const runStartMs = Date.parse(r.started_at);
2474
+ const startMs = runStartMs - slackMs;
2475
+ const endMs = Date.parse(r.finished_at) + slackMs;
2476
+ const family = _phaseFamilyFor(r.script);
2477
+ // Step 1: window + family match (over-broad — catches overlapping cycles).
2478
+ const windowMatched = sessionList.filter(s =>
2479
+ s.ts >= startMs && s.ts <= endMs && (!family || family.has(s.script))
2480
+ );
2481
+ // Step 2: cycle_id disambiguation. Group matched sessions by cycle_id and
2482
+ // pick the group whose earliest session starts closest to this run's
2483
+ // started_at — that's the run's own cycle. Drop sessions from other
2484
+ // cycle_ids. If no session carries a cycle_id, fall back to the full
2485
+ // window-matched set (older sessions / pipelines that don't set
2486
+ // SA_CYCLE_ID).
2487
+ let attributed;
2488
+ const byCycle = new Map();
2489
+ for (const s of windowMatched) {
2490
+ if (!s.cycleId) continue;
2491
+ let g = byCycle.get(s.cycleId);
2492
+ if (!g) { g = { earliest: s.ts, sessions: [] }; byCycle.set(s.cycleId, g); }
2493
+ if (s.ts < g.earliest) g.earliest = s.ts;
2494
+ g.sessions.push(s);
2495
+ }
2496
+ if (byCycle.size > 0) {
2497
+ let bestCycle = null, bestDelta = Infinity;
2498
+ for (const [cid, g] of byCycle) {
2499
+ const delta = Math.abs(g.earliest - runStartMs);
2500
+ if (delta < bestDelta) { bestDelta = delta; bestCycle = cid; }
2501
+ }
2502
+ attributed = byCycle.get(bestCycle).sessions;
2503
+ } else {
2504
+ attributed = windowMatched;
2505
+ }
2506
+ const byPhase = {};
2507
+ let totalOrch = 0, totalEst = 0, totalSub = 0;
2508
+ let sessionsAll = 0, sessionsWithSdk = 0;
2509
+ for (const s of attributed) {
2510
+ const key = s.script;
2511
+ const cur = byPhase[key] || { phase: key, sessions: 0, sessions_with_sdk: 0, orch: 0, est: 0, sub: 0 };
2512
+ cur.sessions += 1;
2513
+ if (s.hasSdk) cur.sessions_with_sdk += 1;
2514
+ cur.orch += s.orch;
2515
+ cur.est += s.est;
2516
+ cur.sub += s.sub;
2517
+ byPhase[key] = cur;
2518
+ sessionsAll += 1;
2519
+ if (s.hasSdk) sessionsWithSdk += 1;
2520
+ totalOrch += s.orch;
2521
+ totalEst += s.est;
2522
+ totalSub += s.sub;
2523
+ }
2524
+ if (!sessionsAll) continue;
2525
+ const phases = Object.values(byPhase)
2526
+ .sort((a, b) => b.orch - a.orch || b.sessions - a.sessions)
2527
+ .map(p => ({
2528
+ phase: p.phase,
2529
+ sessions: p.sessions,
2530
+ sessions_with_sdk: p.sessions_with_sdk,
2531
+ sessions_missing_sdk: p.sessions - p.sessions_with_sdk,
2532
+ total: Number(p.orch.toFixed(6)),
2533
+ orch: Number(p.orch.toFixed(6)),
2534
+ sub: Number(p.sub.toFixed(6)),
2535
+ est: Number(p.est.toFixed(6)),
2536
+ }));
2537
+ if (!r.result) r.result = {};
2538
+ // Preserve provenance: the original shell-log cost (parseFloat'd from the
2539
+ // run_monitor line) is kept under cost_usd_from_log so an operator can
2540
+ // see what the wrapper reported vs what we recomputed.
2541
+ if (typeof r.result.cost_usd === 'number') {
2542
+ r.result.cost_usd_from_log = r.result.cost_usd;
2543
+ }
2544
+ // SDK-only displayed total. Sessions missing SDK contribute 0 — the
2545
+ // sessions_missing_sdk counter on each phase row is the only signal that
2546
+ // real spend went unrecorded.
2547
+ r.result.cost_breakdown = {
2548
+ total: Number(totalOrch.toFixed(6)),
2549
+ orchestrator: Number(totalOrch.toFixed(6)),
2550
+ subagent: Number(totalSub.toFixed(6)),
2551
+ estimated: Number(totalEst.toFixed(6)),
2552
+ sessions: sessionsAll,
2553
+ sessions_with_sdk: sessionsWithSdk,
2554
+ sessions_missing_sdk: sessionsAll - sessionsWithSdk,
2555
+ phases,
2556
+ };
2557
+ r.result.cost_usd = r.result.cost_breakdown.total;
2558
+ r.result.cost_usd_orchestrator = r.result.cost_breakdown.orchestrator;
2559
+ r.result.cost_usd_estimated = r.result.cost_breakdown.estimated;
2560
+ r.result.cost_usd_subagent = r.result.cost_breakdown.subagent;
2561
+ r.result.cost_sessions_missing_sdk = r.result.cost_breakdown.sessions_missing_sdk;
2562
+ }
2563
+ }
2564
+
2287
2565
  // DB-backed enrichment uses run.started_at and run.finished_at to define a
2288
2566
  // window, plus a small slack on each side for clock skew between the shell
2289
2567
  // trap that wrote the run_monitor line and the page row's completed_at.
@@ -2454,6 +2732,12 @@ const bookingsPerDayCache = new Map();
2454
2732
  // post_link_clicks (per-hit log) filtered to is_bot=false so we count humans
2455
2733
  // only; joins to post_links + posts so platform/project filters apply.
2456
2734
  const clicksPerDayCache = new Map();
2735
+ // Posts-per-day: cached by days|platform|project. Backs the Trends-tab
2736
+ // "Views / Post" ratio denominator. Counts posts.posted_at grouped by UTC date,
2737
+ // excluding moltbook/github/github_issues to match the views/upvotes/comments
2738
+ // scope (those platforms don't surface views, so including them would
2739
+ // understate views-per-post for the comparable platforms).
2740
+ const postsPerDayCache = new Map();
2457
2741
  // Funnel-per-day (PostHog-backed metrics): cached by days.
2458
2742
  const funnelPerDayCache = new Map();
2459
2743
  // Cost-per-day: Claude API session cost attributed to days an activity row
@@ -3520,6 +3804,7 @@ async function handleApi(req, res) {
3520
3804
  await enrichPostCommentsTwitterRuns(runs);
3521
3805
  await enrichPostCommentsRedditRuns(runs);
3522
3806
  await enrichSeoRuns(runs);
3807
+ await enrichRunsCostBreakdown(runs);
3523
3808
  // Prepend in-progress pipelines so they appear at the top of the table.
3524
3809
  // Always included regardless of the hours window — a long-running job
3525
3810
  // started before the window is still relevant right now.
@@ -3626,28 +3911,37 @@ async function handleApi(req, res) {
3626
3911
  "), session_counts AS (" +
3627
3912
  "SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id" +
3628
3913
  "), session_cost AS (" +
3914
+ // SDK-only mode (2026-05-15): per_row_cost = orchestrator_cost_usd
3915
+ // alone, split evenly across activity rows. NULL when the wrapper
3916
+ // didn't pass --orchestrator-cost-usd (e.g. shell wrappers that omit
3917
+ // --output-format json so Claude never emits total_cost_usd). The
3918
+ // transcript estimate and subagent dollars are computed from a local
3919
+ // pricing table — kept in the JSON payload for diagnostics only,
3920
+ // never folded into the displayed total.
3629
3921
  "SELECT cs.session_id, " +
3630
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost, " +
3922
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost, " +
3631
3923
  "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_orchestrator, " +
3632
- "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_estimated " +
3924
+ "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_estimated, " +
3925
+ "(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_subagent " +
3633
3926
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id" +
3634
3927
  ") " +
3635
3928
  "SELECT json_agg(row_to_json(r)) FROM (" +
3636
- "SELECT * FROM (SELECT posted_at AS occurred_at, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN 'posted_thread' ELSE 'posted_comment' END AS type, platform, our_account AS actor, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN COALESCE(thread_title, LEFT(our_content, 280)) ELSE LEFT(our_content, 280) END AS summary, engagement_style AS detail, our_url AS link, ('p' || posts.id) AS key, project_name AS project, sc.per_row_cost AS cost_usd, sc.per_row_cost_orchestrator AS cost_usd_orchestrator, sc.per_row_cost_estimated AS cost_usd_estimated, c.name AS campaign_name, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_title END AS context_title, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_url END AS context_url, LEFT(our_content, 3000) AS body FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id LEFT JOIN campaigns c ON c.id = posts.campaign_id WHERE posted_at IS NOT NULL AND our_content <> '(mention - no original post)' ORDER BY posted_at DESC LIMIT 150) x1 " +
3637
- "UNION ALL SELECT * FROM (SELECT r2.replied_at, 'replied', r2.platform, r2.their_author, COALESCE(LEFT(r2.our_reply_content, 280), LEFT(r2.their_content, 280)), CASE WHEN r2.is_recommendation THEN 'rec · ' || COALESCE(r2.engagement_style, '') ELSE r2.engagement_style END, r2.our_reply_url, ('r' || r2.id), p.project_name, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, c2.name, p.thread_title, p.thread_url, NULL::text FROM replies r2 LEFT JOIN posts p ON p.id = r2.post_id LEFT JOIN session_cost sc ON sc.session_id = r2.claude_session_id LEFT JOIN campaigns c2 ON c2.id = r2.campaign_id WHERE r2.status='replied' AND r2.replied_at IS NOT NULL ORDER BY r2.replied_at DESC LIMIT 150) x2 " +
3638
- "UNION ALL SELECT * FROM (SELECT COALESCE(r3.processing_at, r3.discovered_at), 'skipped', r3.platform, r3.their_author, LEFT(r3.their_content, 140), r3.skip_reason, r3.their_comment_url, ('s' || r3.id), p.project_name, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, c3.name, p.thread_title, p.thread_url, NULL::text FROM replies r3 LEFT JOIN posts p ON p.id = r3.post_id LEFT JOIN session_cost sc ON sc.session_id = r3.claude_session_id LEFT JOIN campaigns c3 ON c3.id = r3.campaign_id WHERE r3.status='skipped' ORDER BY COALESCE(r3.processing_at, r3.discovered_at) DESC LIMIT 150) x3 " +
3639
- "UNION ALL SELECT * FROM (SELECT COALESCE(source_timestamp, received_at), 'mention', platform, author, COALESCE(title, LEFT(body, 140)), sentiment, url, ('m' || id), NULL::text, NULL::numeric, NULL::numeric, NULL::numeric, NULL::text, NULL::text, NULL::text, NULL::text FROM octolens_mentions ORDER BY COALESCE(source_timestamp, received_at) DESC LIMIT 150) x4 " +
3640
- "UNION ALL SELECT * FROM (SELECT sent_at, 'dm_sent', platform, their_author, LEFT(our_dm_content, 140), NULL::text, chat_url, ('d' || dms.id), NULL::text, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM dms LEFT JOIN session_cost sc ON sc.session_id = dms.claude_session_id WHERE status='sent' AND sent_at IS NOT NULL ORDER BY sent_at DESC LIMIT 150) x5 " +
3641
- "UNION ALL SELECT * FROM (SELECT m.message_at, 'dm_reply_sent', d.platform, d.their_author, LEFT(m.content, 140), NULL::text, d.chat_url, ('dr' || m.id), NULL::text, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, c5.name, NULL::text, NULL::text, NULL::text FROM dm_messages m JOIN dms d ON d.id = m.dm_id LEFT JOIN session_cost sc ON sc.session_id = m.claude_session_id LEFT JOIN campaigns c5 ON c5.id = m.campaign_id WHERE m.direction = 'outbound' AND EXISTS (SELECT 1 FROM dm_messages m2 WHERE m2.dm_id = m.dm_id AND m2.direction = 'inbound' AND m2.message_at < m.message_at) ORDER BY m.message_at DESC LIMIT 150) x5b " +
3642
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_serp', 'seo', product, keyword, slug, page_url, ('k' || sk.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk LEFT JOIN session_cost sc ON sc.session_id = sk.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND COALESCE(source, '') NOT IN ('reddit', 'top_page', 'top_post', 'roundup') ORDER BY completed_at DESC LIMIT 150) x6 " +
3643
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_gsc', 'seo', product, query, page_slug, page_url, ('g' || gq.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM gsc_queries gq LEFT JOIN session_cost sc ON sc.session_id = gq.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL ORDER BY completed_at DESC LIMIT 150) x7 " +
3644
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_reddit', 'seo', product, keyword, slug, page_url, ('kr' || sk2.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk2 LEFT JOIN session_cost sc ON sc.session_id = sk2.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'reddit' ORDER BY completed_at DESC LIMIT 150) x8 " +
3645
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_top', 'seo', product, keyword, slug, page_url, ('kt' || sk3.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk3 LEFT JOIN session_cost sc ON sc.session_id = sk3.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'top_page' ORDER BY completed_at DESC LIMIT 150) x8b " +
3646
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_top_post', 'seo', product, keyword, slug, page_url, ('ktp' || sk5.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk5 LEFT JOIN session_cost sc ON sc.session_id = sk5.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'top_post' ORDER BY completed_at DESC LIMIT 150) x8tp " +
3647
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_roundup', 'seo', product, keyword, slug, page_url, ('kru' || sk4.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk4 LEFT JOIN session_cost sc ON sc.session_id = sk4.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'roundup' ORDER BY completed_at DESC LIMIT 150) x8r " +
3648
- "UNION ALL SELECT * FROM (SELECT completed_at, 'page_improved', 'seo', product, LEFT(COALESCE(rationale, diff_summary, page_path), 140), page_path, page_url, ('pi' || spi.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_page_improvements spi LEFT JOIN session_cost sc ON sc.session_id = spi.claude_session_id WHERE completed_at IS NOT NULL AND status = 'committed' ORDER BY completed_at DESC LIMIT 150) x8c " +
3649
- "UNION ALL SELECT * FROM (SELECT expired_at, 'page_expired', 'seo', product, regexp_replace(source_path, '^.*/', ''), 'imp=' || impressions_30d || ' clicks=0 age=' || COALESCE(file_age_days::int, 0) || 'd ' || COALESCE(reason,''), page_url, ('xp' || sep.id), product, NULL::numeric, NULL::numeric, NULL::numeric, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_expired_pages sep ORDER BY expired_at DESC LIMIT 150) x8d " +
3650
- "UNION ALL SELECT * FROM (SELECT resurrected_at AS occurred_at, 'resurrected' AS type, platform, our_account AS actor, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN COALESCE(thread_title, LEFT(our_content, 280)) ELSE LEFT(our_content, 280) END AS summary, NULL::text AS detail, our_url AS link, ('rr' || posts.id) AS key, project_name AS project, sc.per_row_cost AS cost_usd, sc.per_row_cost_orchestrator AS cost_usd_orchestrator, sc.per_row_cost_estimated AS cost_usd_estimated, c9.name AS campaign_name, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_title END AS context_title, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_url END AS context_url, LEFT(our_content, 3000) AS body FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id LEFT JOIN campaigns c9 ON c9.id = posts.campaign_id WHERE resurrected_at IS NOT NULL AND our_content <> '(mention - no original post)' ORDER BY resurrected_at DESC LIMIT 150) x9 " +
3929
+ "SELECT * FROM (SELECT posted_at AS occurred_at, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN 'posted_thread' ELSE 'posted_comment' END AS type, platform, our_account AS actor, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN COALESCE(thread_title, LEFT(our_content, 280)) ELSE LEFT(our_content, 280) END AS summary, engagement_style AS detail, our_url AS link, ('p' || posts.id) AS key, project_name AS project, sc.per_row_cost AS cost_usd, sc.per_row_cost_orchestrator AS cost_usd_orchestrator, sc.per_row_cost_estimated AS cost_usd_estimated, sc.per_row_cost_subagent AS cost_usd_subagent, c.name AS campaign_name, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_title END AS context_title, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_url END AS context_url, LEFT(our_content, 3000) AS body FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id LEFT JOIN campaigns c ON c.id = posts.campaign_id WHERE posted_at IS NOT NULL AND our_content <> '(mention - no original post)' ORDER BY posted_at DESC LIMIT 150) x1 " +
3930
+ "UNION ALL SELECT * FROM (SELECT r2.replied_at, 'replied', r2.platform, r2.their_author, COALESCE(LEFT(r2.our_reply_content, 280), LEFT(r2.their_content, 280)), CASE WHEN r2.is_recommendation THEN 'rec · ' || COALESCE(r2.engagement_style, '') ELSE r2.engagement_style END, r2.our_reply_url, ('r' || r2.id), p.project_name, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, c2.name, p.thread_title, p.thread_url, NULL::text FROM replies r2 LEFT JOIN posts p ON p.id = r2.post_id LEFT JOIN session_cost sc ON sc.session_id = r2.claude_session_id LEFT JOIN campaigns c2 ON c2.id = r2.campaign_id WHERE r2.status='replied' AND r2.replied_at IS NOT NULL ORDER BY r2.replied_at DESC LIMIT 150) x2 " +
3931
+ "UNION ALL SELECT * FROM (SELECT COALESCE(r3.processing_at, r3.discovered_at), 'skipped', r3.platform, r3.their_author, LEFT(r3.their_content, 140), r3.skip_reason, r3.their_comment_url, ('s' || r3.id), p.project_name, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, c3.name, p.thread_title, p.thread_url, NULL::text FROM replies r3 LEFT JOIN posts p ON p.id = r3.post_id LEFT JOIN session_cost sc ON sc.session_id = r3.claude_session_id LEFT JOIN campaigns c3 ON c3.id = r3.campaign_id WHERE r3.status='skipped' ORDER BY COALESCE(r3.processing_at, r3.discovered_at) DESC LIMIT 150) x3 " +
3932
+ "UNION ALL SELECT * FROM (SELECT COALESCE(source_timestamp, received_at), 'mention', platform, author, COALESCE(title, LEFT(body, 140)), sentiment, url, ('m' || id), NULL::text, NULL::numeric, NULL::numeric, NULL::numeric, NULL::numeric, NULL::text, NULL::text, NULL::text, NULL::text FROM octolens_mentions ORDER BY COALESCE(source_timestamp, received_at) DESC LIMIT 150) x4 " +
3933
+ "UNION ALL SELECT * FROM (SELECT sent_at, 'dm_sent', platform, their_author, LEFT(our_dm_content, 140), NULL::text, chat_url, ('d' || dms.id), NULL::text, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM dms LEFT JOIN session_cost sc ON sc.session_id = dms.claude_session_id WHERE status='sent' AND sent_at IS NOT NULL ORDER BY sent_at DESC LIMIT 150) x5 " +
3934
+ "UNION ALL SELECT * FROM (SELECT m.message_at, 'dm_reply_sent', d.platform, d.their_author, LEFT(m.content, 140), NULL::text, d.chat_url, ('dr' || m.id), NULL::text, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, c5.name, NULL::text, NULL::text, NULL::text FROM dm_messages m JOIN dms d ON d.id = m.dm_id LEFT JOIN session_cost sc ON sc.session_id = m.claude_session_id LEFT JOIN campaigns c5 ON c5.id = m.campaign_id WHERE m.direction = 'outbound' AND EXISTS (SELECT 1 FROM dm_messages m2 WHERE m2.dm_id = m.dm_id AND m2.direction = 'inbound' AND m2.message_at < m.message_at) ORDER BY m.message_at DESC LIMIT 150) x5b " +
3935
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_twitter', 'seo', product, keyword, slug, page_url, ('k' || sk.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk LEFT JOIN session_cost sc ON sc.session_id = sk.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'twitter' ORDER BY completed_at DESC LIMIT 150) x6 " +
3936
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_misc', 'seo', product, keyword, slug, page_url, ('km' || sk6.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk6 LEFT JOIN session_cost sc ON sc.session_id = sk6.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND COALESCE(source, '') NOT IN ('reddit', 'top_page', 'top_post', 'roundup', 'twitter') ORDER BY completed_at DESC LIMIT 150) x6m " +
3937
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_gsc', 'seo', product, query, page_slug, page_url, ('g' || gq.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM gsc_queries gq LEFT JOIN session_cost sc ON sc.session_id = gq.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL ORDER BY completed_at DESC LIMIT 150) x7 " +
3938
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_reddit', 'seo', product, keyword, slug, page_url, ('kr' || sk2.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk2 LEFT JOIN session_cost sc ON sc.session_id = sk2.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'reddit' ORDER BY completed_at DESC LIMIT 150) x8 " +
3939
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_top', 'seo', product, keyword, slug, page_url, ('kt' || sk3.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk3 LEFT JOIN session_cost sc ON sc.session_id = sk3.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'top_page' ORDER BY completed_at DESC LIMIT 150) x8b " +
3940
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_top_post', 'seo', product, keyword, slug, page_url, ('ktp' || sk5.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk5 LEFT JOIN session_cost sc ON sc.session_id = sk5.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'top_post' ORDER BY completed_at DESC LIMIT 150) x8tp " +
3941
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_roundup', 'seo', product, keyword, slug, page_url, ('kru' || sk4.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk4 LEFT JOIN session_cost sc ON sc.session_id = sk4.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'roundup' ORDER BY completed_at DESC LIMIT 150) x8r " +
3942
+ "UNION ALL SELECT * FROM (SELECT completed_at, 'page_improved', 'seo', product, LEFT(COALESCE(rationale, diff_summary, page_path), 140), page_path, page_url, ('pi' || spi.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_page_improvements spi LEFT JOIN session_cost sc ON sc.session_id = spi.claude_session_id WHERE completed_at IS NOT NULL AND status = 'committed' ORDER BY completed_at DESC LIMIT 150) x8c " +
3943
+ "UNION ALL SELECT * FROM (SELECT expired_at, 'page_expired', 'seo', product, regexp_replace(source_path, '^.*/', ''), 'imp=' || impressions_30d || ' clicks=0 age=' || COALESCE(file_age_days::int, 0) || 'd ' || COALESCE(reason,''), page_url, ('xp' || sep.id), product, NULL::numeric, NULL::numeric, NULL::numeric, NULL::numeric, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_expired_pages sep ORDER BY expired_at DESC LIMIT 150) x8d " +
3944
+ "UNION ALL SELECT * FROM (SELECT resurrected_at AS occurred_at, 'resurrected' AS type, platform, our_account AS actor, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN COALESCE(thread_title, LEFT(our_content, 280)) ELSE LEFT(our_content, 280) END AS summary, NULL::text AS detail, our_url AS link, ('rr' || posts.id) AS key, project_name AS project, sc.per_row_cost AS cost_usd, sc.per_row_cost_orchestrator AS cost_usd_orchestrator, sc.per_row_cost_estimated AS cost_usd_estimated, sc.per_row_cost_subagent AS cost_usd_subagent, c9.name AS campaign_name, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_title END AS context_title, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_url END AS context_url, LEFT(our_content, 3000) AS body FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id LEFT JOIN campaigns c9 ON c9.id = posts.campaign_id WHERE resurrected_at IS NOT NULL AND our_content <> '(mention - no original post)' ORDER BY resurrected_at DESC LIMIT 150) x9 " +
3651
3945
  "ORDER BY 1 DESC LIMIT 500) r";
3652
3946
  return (async () => {
3653
3947
  const rows = await pq(q);
@@ -3662,6 +3956,7 @@ async function handleApi(req, res) {
3662
3956
  delete e.cost_usd;
3663
3957
  delete e.cost_usd_orchestrator;
3664
3958
  delete e.cost_usd_estimated;
3959
+ delete e.cost_usd_subagent;
3665
3960
  });
3666
3961
  }
3667
3962
  return json(res, { events });
@@ -4152,7 +4447,16 @@ async function handleApi(req, res) {
4152
4447
  // started with a prospect."
4153
4448
  parts.push("SELECT 'dm_sent' AS type, d.platform AS pl FROM dms d WHERE EXISTS (SELECT 1 FROM dm_messages m WHERE m.dm_id = d.id AND m.direction='outbound' AND m.message_at >= NOW() - " + win + " AND NOT EXISTS (SELECT 1 FROM dm_messages m2 WHERE m2.dm_id = d.id AND m2.direction='outbound' AND m2.message_at < m.message_at))" + dmsAliasedPc.clause);
4154
4449
  parts.push("SELECT 'dm_reply_sent' AS type, d.platform AS pl FROM dm_messages m JOIN dms d ON d.id = m.dm_id WHERE m.direction='outbound' AND m.message_at >= NOW() - " + win + " AND EXISTS (SELECT 1 FROM dm_messages m2 WHERE m2.dm_id = m.dm_id AND m2.direction='inbound' AND m2.message_at < m.message_at)" + dmsAliasedPc.clause);
4155
- parts.push("SELECT 'page_published_serp' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND COALESCE(source, '') NOT IN ('reddit', 'top_page', 'top_post', 'roundup')" + seoProdPc.clause);
4450
+ // Pre-2026-05-16: a single 'page_published_serp' bucket caught every
4451
+ // seo_keywords row whose source was not (reddit, top_page, top_post,
4452
+ // roundup). The real SERP pipeline was unloaded 2026-04-17; the cards
4453
+ // labelled "SERP SEO" since then have actually been Twitter-cycle
4454
+ // page-gen output (twitter_gen_links.py -> generate_page.py --trigger
4455
+ // twitter, source='twitter'). Split into an honest twitter bucket and a
4456
+ // misc catch-all for legacy/edge sources (existing_page, gsc,
4457
+ // suggestion:*, competitor:*, topic_template, etc).
4458
+ parts.push("SELECT 'page_published_twitter' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND source = 'twitter'" + seoProdPc.clause);
4459
+ parts.push("SELECT 'page_published_misc' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND COALESCE(source, '') NOT IN ('reddit', 'top_page', 'top_post', 'roundup', 'twitter')" + seoProdPc.clause);
4156
4460
  parts.push("SELECT 'page_published_gsc' AS type, 'seo' AS pl FROM gsc_queries WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL" + seoProdPc.clause);
4157
4461
  parts.push("SELECT 'page_published_reddit' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND source='reddit'" + seoProdPc.clause);
4158
4462
  parts.push("SELECT 'page_published_top' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND source='top_page'" + seoProdPc.clause);
@@ -4317,6 +4621,64 @@ async function handleApi(req, res) {
4317
4621
  })().catch(e => json(res, { error: e.message }, 500));
4318
4622
  }
4319
4623
 
4624
+ // GET /api/posts/per-day?days=N&platform=X&project=Y - count of posts we
4625
+ // made per day, sourced from posts.posted_at. Same platform/project filter
4626
+ // shape as views/upvotes/comments so Trends-tab filters behave identically.
4627
+ // Excludes moltbook/github/github_issues to match the views denominator
4628
+ // scope (those platforms don't surface views, so including their posts
4629
+ // would understate the views-per-post ratio). Backs the Trends-tab
4630
+ // "Views / Post" pill.
4631
+ if (p === '/api/posts/per-day' && req.method === 'GET') {
4632
+ if (!req.user.admin) return json(res, { error: 'forbidden' }, 403);
4633
+ const url = new URL(req.url, 'http://localhost');
4634
+ const days = Math.max(1, Math.min(365, parseInt(url.searchParams.get('days') || '30', 10) || 30));
4635
+ const rawPlatform = (url.searchParams.get('platform') || '').trim().toLowerCase();
4636
+ const platform = (rawPlatform === '' || rawPlatform === 'all') ? '' :
4637
+ (rawPlatform === 'x' ? 'twitter' : rawPlatform);
4638
+ const platformOk = platform === '' || /^[a-z0-9_]{1,32}$/.test(platform);
4639
+ if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
4640
+ const rawProject = (url.searchParams.get('project') || '').trim();
4641
+ const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4642
+ const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4643
+ if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4644
+ const cacheKey = days + '|' + platform + '|' + project;
4645
+ const cached = postsPerDayCache.get(cacheKey);
4646
+ if (cached && Date.now() - cached.at < 300000) {
4647
+ return json(res, { days, rows: cached.value, cachedAt: cached.at });
4648
+ }
4649
+ const platformFilter = platform
4650
+ ? " AND CASE WHEN LOWER(p.platform) = 'x' THEN 'twitter' ELSE LOWER(p.platform) END = '" + platform + "'"
4651
+ : '';
4652
+ const projectFilter = project
4653
+ ? " AND p.project_name = '" + project.replace(/'/g, "''") + "'"
4654
+ : '';
4655
+ // Split posts_made into threads_made (we authored the thread itself) vs
4656
+ // comments_made (we engaged on someone else's thread). Matches the
4657
+ // /api/activity classifier: thread iff thread_url = our_url AND
4658
+ // (thread_author IS NULL OR thread_author = our_account).
4659
+ const threadClause =
4660
+ "p.thread_url = p.our_url AND (p.thread_author IS NULL OR p.thread_author = p.our_account)";
4661
+ const q =
4662
+ "SELECT json_agg(row_to_json(r)) FROM (" +
4663
+ "SELECT to_char((p.posted_at AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') AS day, " +
4664
+ "COUNT(*)::bigint AS posts_made, " +
4665
+ "SUM(CASE WHEN " + threadClause + " THEN 1 ELSE 0 END)::bigint AS threads_made, " +
4666
+ "SUM(CASE WHEN " + threadClause + " THEN 0 ELSE 1 END)::bigint AS comments_made " +
4667
+ "FROM posts p " +
4668
+ "WHERE p.posted_at IS NOT NULL " +
4669
+ "AND p.posted_at >= CURRENT_DATE - INTERVAL '" + days + " days' " +
4670
+ "AND LOWER(p.platform) NOT IN ('moltbook', 'github', 'github_issues')" +
4671
+ platformFilter + projectFilter + " " +
4672
+ "GROUP BY day ORDER BY day ASC" +
4673
+ ") r";
4674
+ return (async () => {
4675
+ const rows = await pq(q);
4676
+ const value = (rows && rows.length && rows[0].json_agg) ? rows[0].json_agg : [];
4677
+ postsPerDayCache.set(cacheKey, { at: Date.now(), value });
4678
+ return json(res, { days, rows: value });
4679
+ })().catch(e => json(res, { error: e.message }, 500));
4680
+ }
4681
+
4320
4682
  // GET /api/bookings/per-day?days=N - real Cal.com bookings per day from
4321
4683
  // the separate BOOKINGS_DATABASE_URL Neon DB. Filters out test bookings
4322
4684
  // the same way project_stats_json.py does (attendee_email NOT ILIKE
@@ -4450,7 +4812,8 @@ async function handleApi(req, res) {
4450
4812
  const sumCols =
4451
4813
  "COALESCE(SUM(sc.per_row_cost), 0)::numeric(12,4) AS total_cost_usd, " +
4452
4814
  "COALESCE(SUM(sc.per_row_cost_orchestrator), 0)::numeric(12,4) AS total_cost_usd_orchestrator, " +
4453
- "COALESCE(SUM(sc.per_row_cost_estimated), 0)::numeric(12,4) AS total_cost_usd_estimated";
4815
+ "COALESCE(SUM(sc.per_row_cost_estimated), 0)::numeric(12,4) AS total_cost_usd_estimated, " +
4816
+ "COALESCE(SUM(sc.per_row_cost_subagent), 0)::numeric(12,4) AS total_cost_usd_subagent";
4454
4817
  if (includeThread) {
4455
4818
  rowQueries.push(
4456
4819
  "SELECT 'thread' AS type, COUNT(*)::int AS count, " + sumCols + " " +
@@ -4489,15 +4852,46 @@ async function handleApi(req, res) {
4489
4852
  "WITH src AS (" + parts.join(' UNION ALL ') + "), " +
4490
4853
  "session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
4491
4854
  "session_cost AS (SELECT cs.session_id, " +
4492
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
4855
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
4493
4856
  "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_orchestrator, " +
4494
- "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated " +
4857
+ "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated, " +
4858
+ "(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_subagent " +
4495
4859
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id) " +
4496
4860
  "SELECT json_agg(row_to_json(r)) FROM (" + rowQueries.join(' UNION ALL ') + ") r";
4861
+ // Per-phase (script) cost rollup over the same window. Groups every
4862
+ // claude_sessions row in the window by its `script` column (e.g.
4863
+ // run-twitter-cycle-scan, post_reddit, seo_generate_page) and surfaces
4864
+ // total + orchestrator + subagent + estimate lanes. Independent of the
4865
+ // activity-type rollup above: a single post_reddit session might produce
4866
+ // 0 or 1 thread row, but its cost still shows up in the per-phase view.
4867
+ // SDK-only per-phase rollup. total_cost_usd = SUM(orchestrator_cost_usd)
4868
+ // — no transcript estimate, no subagent fold-in. Phases with 0% SDK
4869
+ // coverage (wrapper doesn't capture --orchestrator-cost-usd) show
4870
+ // total $0 but a non-zero sessions_missing_sdk count, which is the
4871
+ // signal to investigate. Also surfaces the estimate and subagent
4872
+ // columns as diagnostic-only fields.
4873
+ const phaseQ =
4874
+ "SELECT script AS phase, " +
4875
+ "COUNT(*)::int AS sessions, " +
4876
+ "COUNT(orchestrator_cost_usd)::int AS sessions_with_sdk, " +
4877
+ "(COUNT(*) - COUNT(orchestrator_cost_usd))::int AS sessions_missing_sdk, " +
4878
+ "COALESCE(SUM(orchestrator_cost_usd), 0)::numeric(12,4) AS total_cost_usd, " +
4879
+ "COALESCE(SUM(orchestrator_cost_usd), 0)::numeric(12,4) AS total_cost_usd_orchestrator, " +
4880
+ "COALESCE(SUM(total_cost_usd), 0)::numeric(12,4) AS total_cost_usd_estimated, " +
4881
+ "COALESCE(SUM(subagent_cost_usd), 0)::numeric(12,4) AS total_cost_usd_subagent " +
4882
+ "FROM claude_sessions " +
4883
+ "WHERE started_at >= NOW() - " + win + " " +
4884
+ "GROUP BY script " +
4885
+ "HAVING COUNT(*) > 0 " +
4886
+ "ORDER BY total_cost_usd DESC, sessions DESC " +
4887
+ "LIMIT 50";
4497
4888
  return (async () => {
4498
4889
  const dbRows = await pq(q);
4499
4890
  const value = (dbRows && dbRows.length && dbRows[0].json_agg) ? dbRows[0].json_agg : [];
4500
- return json(res, { windowHours, platform: plat || 'all', rows: value });
4891
+ let phases = [];
4892
+ try { phases = await pq(phaseQ) || []; }
4893
+ catch (e) { console.error('[cost/stats] phase query failed:', e && e.message || e); }
4894
+ return json(res, { windowHours, platform: plat || 'all', rows: value, phases });
4501
4895
  })().catch(e => json(res, { error: e.message }, 500));
4502
4896
  }
4503
4897
 
@@ -4618,7 +5012,7 @@ async function handleApi(req, res) {
4618
5012
  "WITH src AS (" + parts.join(' UNION ALL ') + "), " +
4619
5013
  "session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
4620
5014
  "session_cost AS (SELECT cs.session_id, " +
4621
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost " +
5015
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost " +
4622
5016
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id), " +
4623
5017
  "in_window AS (" + inWindow.join(' UNION ALL ') + ") " +
4624
5018
  "SELECT json_agg(row_to_json(r)) FROM (" +
@@ -5125,10 +5519,47 @@ async function handleApi(req, res) {
5125
5519
  const pc = auth.projectClause(req.user, 'project_name', url.searchParams.get('project'));
5126
5520
  if (!pc.ok) return json(res, { posts: [], window: windowKey, platform: platformFilter || 'all', kind: kindFilter });
5127
5521
  if (pc.clause) whereParts.push(pc.clause.replace(/^\s*AND\s+/, ''));
5522
+ // 2026-05-18 dashboard parity for Reddit DM-rail follow-ups.
5523
+ // Reply-to-reply rows (we replied back to someone who replied to our
5524
+ // top-level comment) live in the `replies` table, not `posts`, so they
5525
+ // were invisible on the Top tab even though they have real engagement
5526
+ // (some routinely break 1000 views). LinkedIn migrated the analogous
5527
+ // data into `posts` on 2026-05-11, but the (platform, thread_url) dedup
5528
+ // on /api/v1/posts blocks the same migration for Reddit (most reply-
5529
+ // to-replies share thread_url with our top-level post in the same
5530
+ // thread). UNION-into-/api/top sidesteps that entirely: the `replies`
5531
+ // table stays the source of truth, and the Top tab surfaces both
5532
+ // surfaces. The activity feed (line ~3919) already filters
5533
+ // r.status='replied' so reply-to-replies appear ONCE there.
5534
+ //
5535
+ // Build the same WHERE clause for the replies branch, but referencing
5536
+ // `r2.replied_at` for the time window and `parent.thread_url` semantics
5537
+ // for the kind filter. Replies are by definition never threads, so
5538
+ // kind='threads' must exclude the whole branch.
5539
+ const repliesPc = auth.projectClause(req.user, 'parent.project_name', url.searchParams.get('project'));
5540
+ const repliesWhere = [
5541
+ "r2.platform = 'reddit'", // only Reddit has the gap today
5542
+ "r2.status = 'replied'",
5543
+ "r2.our_reply_id IS NOT NULL",
5544
+ "r2.our_reply_url IS NOT NULL",
5545
+ "r2.our_reply_content IS NOT NULL AND LENGTH(r2.our_reply_content) >= 30",
5546
+ ];
5547
+ if (windowHours != null) {
5548
+ repliesWhere.push("r2.replied_at >= NOW() - INTERVAL '" + windowHours + " hours'");
5549
+ }
5550
+ if (platformFilter && platformFilter !== 'reddit') {
5551
+ // Caller filtered to a non-Reddit platform; replies branch yields nothing.
5552
+ repliesWhere.push("FALSE");
5553
+ }
5554
+ // kind: 'threads' excludes replies entirely; 'comments' and 'all' include.
5555
+ if (kindFilter === 'threads') {
5556
+ repliesWhere.push("FALSE");
5557
+ }
5558
+ if (repliesPc.clause) repliesWhere.push(repliesPc.clause.replace(/^\s*AND\s+/, ''));
5128
5559
  // Moltbook and GitHub have no views metric; return NULL for those so the UI can
5129
5560
  // render a dash instead of a misleading 0. Score still uses COALESCE so they
5130
5561
  // rank alongside other platforms based on upvotes + comments only.
5131
- const q = "SELECT json_agg(row_to_json(r)) FROM (" +
5562
+ const postsBranch =
5132
5563
  "SELECT posts.id, posts.platform, " +
5133
5564
  // Upvotes are reported NET on Reddit/Moltbook (both auto-apply a +1 OP
5134
5565
  // self-upvote on every post). Strip it per row, clamped at 0 so
@@ -5160,7 +5591,8 @@ async function handleApi(req, res) {
5160
5591
  "COALESCE(pl.bot_clicks, 0)::int AS link_bot_clicks, " +
5161
5592
  "COALESCE(pl.backfill_real, 0)::int AS link_backfill_real, " +
5162
5593
  "COALESCE(pl.link_count, 0)::int AS link_count, " +
5163
- "pl.first_code AS link_code " +
5594
+ "pl.first_code AS link_code, " +
5595
+ "'post'::text AS row_kind " +
5164
5596
  "FROM posts LEFT JOIN campaigns c ON c.id = posts.campaign_id " +
5165
5597
  // pl rollup: legacy `total_clicks` reads the post_links.clicks integer
5166
5598
  // (humans-only after 2026-05-07; pre-existing rows include bots).
@@ -5189,6 +5621,50 @@ async function handleApi(req, res) {
5189
5621
  ") pl ON pl.post_id = posts.id " +
5190
5622
  "WHERE " + whereParts.join(' AND ') + " " +
5191
5623
  "ORDER BY upvotes DESC NULLS LAST, comments_count DESC NULLS LAST, views DESC NULLS LAST " +
5624
+ "LIMIT " + limit;
5625
+ // Replies branch: shape-compatible SELECT against `replies` JOIN `posts` (parent)
5626
+ // for thread context. ID is negated to guarantee uniqueness within the UNION
5627
+ // (posts.id and replies.id are independent sequences and would otherwise
5628
+ // collide for sort/key purposes). The FE only uses `id` as a React key, so
5629
+ // negative integers are fine.
5630
+ const repliesBranch =
5631
+ "SELECT (-r2.id)::int AS id, r2.platform, " +
5632
+ // Reddit-only branch today; strip the OP self-upvote like the posts branch.
5633
+ "GREATEST(0, COALESCE(r2.upvotes, 0) - 1)::int AS upvotes, " +
5634
+ "COALESCE(r2.comments_count, 0)::int AS comments_count, " +
5635
+ "COALESCE(r2.views, 0)::int AS views, " +
5636
+ // Same score formula. Views (Reddit only) contribute /100.
5637
+ "(COALESCE(r2.comments_count,0) * 5 " +
5638
+ "+ GREATEST(0, COALESCE(r2.upvotes,0) - 1) * 5 " +
5639
+ "+ COALESCE(r2.views,0) / 100)::int AS score, " +
5640
+ "FALSE AS is_thread, " +
5641
+ "r2.replied_at AS posted_at, " +
5642
+ "r2.engagement_updated_at, " +
5643
+ "r2.our_reply_content AS our_content, " +
5644
+ "r2.our_reply_url AS our_url, " +
5645
+ "parent.thread_url, parent.thread_title, " +
5646
+ "LEFT(COALESCE(parent.thread_content, ''), 400) AS thread_content, " +
5647
+ "parent.our_account, parent.project_name, " +
5648
+ "r2.engagement_style, r2.is_recommendation, " +
5649
+ "cr.name AS campaign_name, " +
5650
+ // No link tracking on reply-to-replies yet (we don't usually drop a CTA
5651
+ // there). All link counters return 0 so they sort to the bottom of
5652
+ // any link-clicks ordering.
5653
+ "0::int AS link_clicks, 0::int AS link_real_clicks, " +
5654
+ "0::int AS link_bot_clicks, 0::int AS link_backfill_real, " +
5655
+ "0::int AS link_count, NULL::text AS link_code, " +
5656
+ "'reply'::text AS row_kind " +
5657
+ "FROM replies r2 " +
5658
+ "LEFT JOIN posts parent ON parent.id = r2.post_id " +
5659
+ "LEFT JOIN campaigns cr ON cr.id = r2.campaign_id " +
5660
+ "WHERE " + repliesWhere.join(' AND ') + " " +
5661
+ "ORDER BY r2.upvotes DESC NULLS LAST, r2.comments_count DESC NULLS LAST, r2.views DESC NULLS LAST " +
5662
+ "LIMIT " + limit;
5663
+ const q = "SELECT json_agg(row_to_json(r)) FROM (" +
5664
+ "SELECT * FROM (" + postsBranch + ") posts_branch " +
5665
+ "UNION ALL " +
5666
+ "SELECT * FROM (" + repliesBranch + ") replies_branch " +
5667
+ "ORDER BY upvotes DESC NULLS LAST, comments_count DESC NULLS LAST, views DESC NULLS LAST " +
5192
5668
  "LIMIT " + limit +
5193
5669
  ") r";
5194
5670
  return (async () => {
@@ -5198,6 +5674,134 @@ async function handleApi(req, res) {
5198
5674
  })().catch(e => json(res, { error: e.message }, 500));
5199
5675
  }
5200
5676
 
5677
+ // GET /api/top/destinations - post links rolled up by target URL.
5678
+ // One row per unique destination URL (e.g. https://s4l.ai vs
5679
+ // https://s4l.ai/ghostwriting vs https://s4l.ai/t/<slug>), with click totals
5680
+ // aggregated across every short code that pointed at that URL. Used by the
5681
+ // "Links" subtab in the Top tab to answer "where are my posts sending
5682
+ // traffic and how many clicks does each destination get?"
5683
+ if (p === '/api/top/destinations' && req.method === 'GET') {
5684
+ const url = new URL(req.url, 'http://localhost');
5685
+ const WINDOW_HOURS = { '24h': 24, '7d': 24*7, '14d': 24*14, '30d': 24*30, '90d': 24*90, 'all': null };
5686
+ const rawWindow = String(url.searchParams.get('window') || '7d').toLowerCase();
5687
+ const windowKey = Object.prototype.hasOwnProperty.call(WINDOW_HOURS, rawWindow) ? rawWindow : '7d';
5688
+ const windowHours = WINDOW_HOURS[windowKey];
5689
+ const rawPlatform = String(url.searchParams.get('platform') || '').toLowerCase().trim();
5690
+ const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github']);
5691
+ const platformFilter = ALLOWED_PLATFORMS.has(rawPlatform) ? rawPlatform : '';
5692
+ const pc = auth.projectClause(req.user, 'pl.project_name', url.searchParams.get('project'));
5693
+ if (!pc.ok) return json(res, { destinations: [], window: windowKey, platform: 'all' });
5694
+ const limit = Math.max(50, Math.min(500, parseInt(url.searchParams.get('limit') || '200', 10) || 200));
5695
+ const whereParts = [];
5696
+ if (windowHours != null) {
5697
+ whereParts.push("pl.minted_at >= NOW() - INTERVAL '" + windowHours + " hours'");
5698
+ }
5699
+ if (platformFilter) {
5700
+ whereParts.push("LOWER(pl.platform) = '" + platformFilter + "'");
5701
+ }
5702
+ if (pc.clause) whereParts.push(pc.clause.replace(/^\s*AND\s+/, ''));
5703
+ const whereSql = whereParts.length ? ('WHERE ' + whereParts.join(' AND ')) : '';
5704
+ // Grouping key is the URL with all query params + trailing slash stripped.
5705
+ // UTM params (utm_source / utm_medium / utm_campaign / utm_term /
5706
+ // utm_content) are baked into every target_url at mint time so each
5707
+ // post gets its own uniquely-tracked URL even when posting at the same
5708
+ // destination; without stripping, the same homepage would split into
5709
+ // 50 rows. Path is preserved (so /ghostwriting stays distinct from /).
5710
+ // Project + platform stay in GROUP BY so a multi-project repo (one
5711
+ // website hosting pages for several projects) keeps them on separate
5712
+ // rows.
5713
+ const q = "SELECT json_agg(row_to_json(r)) FROM (" +
5714
+ "SELECT " +
5715
+ "REGEXP_REPLACE(REGEXP_REPLACE(pl.target_url, '\\?.*$', ''), '/$', '') AS target_url, " +
5716
+ "pl.project_name, pl.platform, " +
5717
+ "COUNT(DISTINCT pl.post_id)::int AS posts, " +
5718
+ "COUNT(*)::int AS codes, " +
5719
+ "COALESCE(SUM(pl.clicks), 0)::int AS legacy_clicks, " +
5720
+ "COALESCE(SUM(plc.real_clicks), 0)::int AS real_clicks, " +
5721
+ "COALESCE(SUM(plc.bot_clicks), 0)::int AS bot_clicks, " +
5722
+ "COALESCE(SUM(pl.real_clicks), 0)::int AS backfill_real, " +
5723
+ "MIN(pl.minted_at) AS first_minted_at, " +
5724
+ "MAX(pl.last_click_at) AS last_click_at " +
5725
+ "FROM post_links pl " +
5726
+ "LEFT JOIN (" +
5727
+ "SELECT code, " +
5728
+ " COUNT(*) FILTER (WHERE is_bot = false)::int AS real_clicks, " +
5729
+ " COUNT(*) FILTER (WHERE is_bot = true)::int AS bot_clicks " +
5730
+ "FROM post_link_clicks GROUP BY code" +
5731
+ ") plc ON plc.code = pl.code " +
5732
+ whereSql + " " +
5733
+ "GROUP BY 1, pl.project_name, pl.platform " +
5734
+ "ORDER BY real_clicks DESC NULLS LAST, legacy_clicks DESC NULLS LAST, codes DESC " +
5735
+ "LIMIT " + limit +
5736
+ ") r";
5737
+ return (async () => {
5738
+ const rows = await pq(q);
5739
+ const destinations = (rows && rows.length && rows[0].json_agg) ? rows[0].json_agg : [];
5740
+ // Server-side classification of each destination URL into a kind bucket
5741
+ // and (when applicable) the audience-page angle. Reads config.json
5742
+ // once per request to look up each project's website host + audience
5743
+ // pages list; classification is plain hostname / path matching. Done
5744
+ // here so every consumer (UI, future CSV exports) sees the same
5745
+ // canonical label without having to re-implement classify logic.
5746
+ let cfg = null;
5747
+ try {
5748
+ cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
5749
+ } catch (_e) { cfg = { projects: [] }; }
5750
+ const projIdx = {};
5751
+ for (const p of (cfg && cfg.projects) || []) {
5752
+ if (!p || !p.name) continue;
5753
+ const host = String(p.website || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase();
5754
+ const audience = ((p.landing_pages || {}).audience_pages) || [];
5755
+ const apIdx = [];
5756
+ for (const a of audience) {
5757
+ try {
5758
+ const u = new URL(a.url);
5759
+ apIdx.push({
5760
+ angle: a.angle,
5761
+ host: (u.hostname || '').toLowerCase().replace(/^www\./, ''),
5762
+ path: (u.pathname || '/').replace(/\/+$/, '') || '/',
5763
+ });
5764
+ } catch (_e) {}
5765
+ }
5766
+ projIdx[p.name] = { website_host: host, audience_pages: apIdx };
5767
+ }
5768
+ const classify = (targetUrl, projectName) => {
5769
+ if (!targetUrl) return { kind: 'other', audience_page_angle: null };
5770
+ let host = '', pathName = '/';
5771
+ try {
5772
+ const u = new URL(targetUrl);
5773
+ host = (u.hostname || '').toLowerCase().replace(/^www\./, '');
5774
+ pathName = (u.pathname || '/').replace(/\/+$/, '') || '/';
5775
+ } catch (_e) { return { kind: 'other', audience_page_angle: null }; }
5776
+ if (/(^|\.)cal\.com$/.test(host) || /(^|\.)calendly\.com$/.test(host)) return { kind: 'booking', audience_page_angle: null };
5777
+ if (host === 'github.com') return { kind: 'github', audience_page_angle: null };
5778
+ const entry = projectName ? projIdx[projectName] : null;
5779
+ // Audience-page exact host+path match wins over generic SUBPAGE.
5780
+ if (entry) {
5781
+ for (const ap of entry.audience_pages || []) {
5782
+ if (ap.host === host && ap.path === pathName) {
5783
+ return { kind: 'audience_page', audience_page_angle: ap.angle || null };
5784
+ }
5785
+ }
5786
+ if (entry.website_host && host === entry.website_host) {
5787
+ if (pathName === '/' || pathName === '') return { kind: 'home', audience_page_angle: null };
5788
+ if (/^\/t\//.test(pathName)) return { kind: 'seo', audience_page_angle: null };
5789
+ return { kind: 'subpage', audience_page_angle: null };
5790
+ }
5791
+ }
5792
+ if (pathName === '/' || pathName === '') return { kind: 'other', audience_page_angle: null };
5793
+ if (/^\/t\//.test(pathName)) return { kind: 'seo', audience_page_angle: null };
5794
+ return { kind: 'external', audience_page_angle: null };
5795
+ };
5796
+ for (const d of destinations) {
5797
+ const c = classify(d.target_url, d.project_name);
5798
+ d.kind = c.kind;
5799
+ d.audience_page_angle = c.audience_page_angle;
5800
+ }
5801
+ return json(res, { destinations, window: windowKey, platform: platformFilter || 'all' });
5802
+ })().catch(e => json(res, { error: e.message }, 500));
5803
+ }
5804
+
5201
5805
  // GET /api/top/links - post short links ranked by click count.
5202
5806
  // Queries post_links joined with posts so the content snippet is available.
5203
5807
  // Returns links with >= 1 click, ordered by clicks desc. Used by the "Links"
@@ -5430,11 +6034,23 @@ async function handleApi(req, res) {
5430
6034
  "AND query IS NOT NULL AND length(trim(query)) > 0 " +
5431
6035
  "), " +
5432
6036
  "cand AS ( " +
5433
- "SELECT 'twitter' AS platform, c.search_topic AS query, " +
6037
+ // Twitter: c.search_topic is the SEED concept (e.g. "vibe coding") while
6038
+ // twitter_search_attempts.query is the literal X advanced-search string
6039
+ // (e.g. '("vibe coded" OR ...) min_faves:30 since:... -filter:replies').
6040
+ // The two never line up textually, so we associate each candidate to
6041
+ // its parent attempt via (batch_id, project_name) and project the
6042
+ // attempt's `query` as the join key. When one project ran multiple
6043
+ // queries in the same batch (~1.5% of cases), this attributes each
6044
+ // candidate to all of them — known minor over-attribution, acceptable
6045
+ // until we add a per-attempt seed column.
6046
+ "SELECT 'twitter' AS platform, a.query, " +
5434
6047
  "COALESCE(c.matched_project, '(none)') AS project_name, c.post_id " +
5435
6048
  "FROM twitter_candidates c " +
6049
+ "JOIN twitter_search_attempts a " +
6050
+ "ON a.batch_id = c.batch_id " +
6051
+ "AND COALESCE(a.project_name, '(none)') = COALESCE(c.matched_project, '(none)') " +
5436
6052
  "WHERE c.discovered_at >= NOW() - INTERVAL '" + windowHours + " hours' " +
5437
- "AND c.search_topic IS NOT NULL " +
6053
+ "AND c.batch_id IS NOT NULL " +
5438
6054
  "UNION ALL " +
5439
6055
  "SELECT 'linkedin', c.search_query, COALESCE(c.matched_project, '(none)'), c.post_id " +
5440
6056
  "FROM linkedin_candidates c " +
@@ -5624,7 +6240,7 @@ async function handleApi(req, res) {
5624
6240
  // suppresses the column. SDK and estimate lanes are surfaced separately
5625
6241
  // so the dashboard tooltip can show both, same UX as cost-stats.
5626
6242
  let costByProject = {};
5627
- let grandCost = 0, grandCostOrch = 0, grandCostEst = 0;
6243
+ let grandCost = 0, grandCostOrch = 0, grandCostEst = 0, grandCostSub = 0;
5628
6244
  if (req.user && req.user.admin) {
5629
6245
  const costSrcParts = [
5630
6246
  "SELECT claude_session_id FROM posts WHERE claude_session_id IS NOT NULL AND posted_at IS NOT NULL",
@@ -5638,24 +6254,24 @@ async function handleApi(req, res) {
5638
6254
  const costWin = "INTERVAL '" + hours + " hours'";
5639
6255
  const costAttributed = [
5640
6256
  "SELECT COALESCE(posts.project_name, '(none)') AS project, " +
5641
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6257
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5642
6258
  "FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id " +
5643
6259
  "WHERE posts.posted_at >= NOW() - " + costWin + " " +
5644
6260
  "AND posts.our_content <> '(mention - no original post)'",
5645
6261
  "SELECT COALESCE(replies.project_name, '(none)') AS project, " +
5646
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6262
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5647
6263
  "FROM replies LEFT JOIN session_cost sc ON sc.session_id = replies.claude_session_id " +
5648
6264
  "WHERE replies.status='replied' AND replies.replied_at >= NOW() - " + costWin,
5649
6265
  "SELECT COALESCE(dms.target_project, '(none)') AS project, " +
5650
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6266
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5651
6267
  "FROM dms LEFT JOIN session_cost sc ON sc.session_id = dms.claude_session_id " +
5652
6268
  "WHERE dms.status='sent' AND dms.sent_at >= NOW() - " + costWin,
5653
6269
  "SELECT COALESCE(seo_keywords.product, '(none)') AS project, " +
5654
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6270
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5655
6271
  "FROM seo_keywords LEFT JOIN session_cost sc ON sc.session_id = seo_keywords.claude_session_id " +
5656
6272
  "WHERE seo_keywords.completed_at >= NOW() - " + costWin + " AND seo_keywords.page_url IS NOT NULL",
5657
6273
  "SELECT COALESCE(gsc_queries.product, '(none)') AS project, " +
5658
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6274
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5659
6275
  "FROM gsc_queries LEFT JOIN session_cost sc ON sc.session_id = gsc_queries.claude_session_id " +
5660
6276
  "WHERE gsc_queries.completed_at >= NOW() - " + costWin + " AND gsc_queries.page_url IS NOT NULL",
5661
6277
  ];
@@ -5663,15 +6279,17 @@ async function handleApi(req, res) {
5663
6279
  "WITH src AS (" + costSrcParts.join(' UNION ALL ') + "), " +
5664
6280
  "session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
5665
6281
  "session_cost AS (SELECT cs.session_id, " +
5666
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
6282
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
5667
6283
  "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_orchestrator, " +
5668
- "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated " +
6284
+ "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated, " +
6285
+ "(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_subagent " +
5669
6286
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id), " +
5670
6287
  "attributed AS (" + costAttributed.join(' UNION ALL ') + ") " +
5671
6288
  "SELECT project, " +
5672
6289
  "COALESCE(SUM(per_row_cost), 0)::numeric(12,4) AS cost_usd, " +
5673
6290
  "COALESCE(SUM(per_row_cost_orchestrator), 0)::numeric(12,4) AS cost_usd_orchestrator, " +
5674
- "COALESCE(SUM(per_row_cost_estimated), 0)::numeric(12,4) AS cost_usd_estimated " +
6291
+ "COALESCE(SUM(per_row_cost_estimated), 0)::numeric(12,4) AS cost_usd_estimated, " +
6292
+ "COALESCE(SUM(per_row_cost_subagent), 0)::numeric(12,4) AS cost_usd_subagent " +
5675
6293
  "FROM attributed GROUP BY project";
5676
6294
  try {
5677
6295
  const costRows = await pq(costQ) || [];
@@ -5680,10 +6298,12 @@ async function handleApi(req, res) {
5680
6298
  const c = Number(r.cost_usd) || 0;
5681
6299
  const co = Number(r.cost_usd_orchestrator) || 0;
5682
6300
  const ce = Number(r.cost_usd_estimated) || 0;
5683
- costByProject[proj] = { cost_usd: c, cost_usd_orchestrator: co, cost_usd_estimated: ce };
6301
+ const cs = Number(r.cost_usd_subagent) || 0;
6302
+ costByProject[proj] = { cost_usd: c, cost_usd_orchestrator: co, cost_usd_estimated: ce, cost_usd_subagent: cs };
5684
6303
  grandCost += c;
5685
6304
  grandCostOrch += co;
5686
6305
  grandCostEst += ce;
6306
+ grandCostSub += cs;
5687
6307
  });
5688
6308
  } catch (e) {
5689
6309
  // Soft fail: log and continue without cost data. Don't block the
@@ -5697,6 +6317,7 @@ async function handleApi(req, res) {
5697
6317
  r.cost_usd = c ? c.cost_usd : 0;
5698
6318
  r.cost_usd_orchestrator = c ? c.cost_usd_orchestrator : 0;
5699
6319
  r.cost_usd_estimated = c ? c.cost_usd_estimated : 0;
6320
+ r.cost_usd_subagent = c ? c.cost_usd_subagent : 0;
5700
6321
  return r;
5701
6322
  };
5702
6323
  projects.forEach(attachCost);
@@ -5735,6 +6356,7 @@ async function handleApi(req, res) {
5735
6356
  grand_cost_usd: grandCost,
5736
6357
  grand_cost_usd_orchestrator: grandCostOrch,
5737
6358
  grand_cost_usd_estimated: grandCostEst,
6359
+ grand_cost_usd_subagent: grandCostSub,
5738
6360
  cost_available: !!(req.user && req.user.admin),
5739
6361
  can_edit_weight: !auth.CLIENT_MODE && !!(req.user && req.user.admin),
5740
6362
  projects,
@@ -6333,6 +6955,19 @@ const HTML = `<!DOCTYPE html>
6333
6955
  #top-pages-container .style-stats-table th,
6334
6956
  #top-pages-container .style-stats-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 10px 10px; }
6335
6957
  #top-pages-container .style-stats-table td[data-col-key="path"] { white-space: normal; overflow: visible; text-overflow: clip; word-break: break-all; }
6958
+
6959
+ #top-links-container .style-stats-table { table-layout: fixed; }
6960
+ #top-links-container .style-stats-table th,
6961
+ #top-links-container .style-stats-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 10px 10px; vertical-align: top; }
6962
+ #top-links-container .style-stats-table td[data-col-key="target_url"] { white-space: normal; overflow: visible; text-overflow: clip; word-break: break-all; }
6963
+ #top-links-container .dest-kind-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; }
6964
+ #top-links-container .dest-kind-home { background: rgba(16,185,129,0.18); color: #10b981; }
6965
+ #top-links-container .dest-kind-subpage { background: rgba(59,130,246,0.18); color: #3b82f6; }
6966
+ #top-links-container .dest-kind-seo { background: rgba(168,85,247,0.18); color: #a855f7; }
6967
+ #top-links-container .dest-kind-booking { background: rgba(245,158,11,0.18); color: #f59e0b; }
6968
+ #top-links-container .dest-kind-github { background: rgba(148,163,184,0.18); color: #94a3b8; }
6969
+ #top-links-container .dest-kind-external { background: rgba(239,68,68,0.18); color: #ef4444; }
6970
+ #top-links-container .dest-kind-other { background: rgba(148,163,184,0.18); color: #94a3b8; }
6336
6971
  /* DMs sub-tab */
6337
6972
  #top-dms-container .style-stats-table { table-layout: fixed; }
6338
6973
  #top-dms-container .style-stats-table th,
@@ -6606,6 +7241,24 @@ const HTML = `<!DOCTYPE html>
6606
7241
  .style-stats-pill:hover { border-color: var(--border-strong); background: var(--bg-hover); }
6607
7242
  .style-stats-pill.active { background: var(--accent-panel-bg); border-color: #3b82f6; color: var(--text); }
6608
7243
 
7244
+ /* In-place loading state for stats containers. Dims the previously-rendered
7245
+ grid/table so it stays visible (no layout jump) but reads clearly as stale.
7246
+ Used by loadActivityStats / loadCohortStats / loadStyleStats while their
7247
+ fetches are in flight. */
7248
+ .is-loading { opacity: 0.5; transition: opacity 0.12s linear; pointer-events: none; }
7249
+
7250
+ /* Disable Stats-tab filter pills while any stats fetch is in flight
7251
+ (body.sa-stats-busy) so users can't queue overlapping requests across
7252
+ rapid pill changes. Covers the three pill rows that drive
7253
+ reloadStatsTabSections: stats window + style-stats platform/project. */
7254
+ body.sa-stats-busy #stats-window-pills .style-stats-pill,
7255
+ body.sa-stats-busy #style-stats-platform-pills .style-stats-pill,
7256
+ body.sa-stats-busy #style-stats-project-pills .style-stats-pill {
7257
+ pointer-events: none;
7258
+ opacity: 0.55;
7259
+ cursor: wait;
7260
+ }
7261
+
6609
7262
  @media (max-width: 600px) { .cards { grid-template-columns: 1fr; } .content { padding: 16px; } }
6610
7263
 
6611
7264
  /* Client-mode auth overlay. Non-admin users see the app with admin-only
@@ -7040,6 +7693,11 @@ const HTML = `<!DOCTYPE html>
7040
7693
  <span class="top-subtab-label">DMs</span>
7041
7694
  <span class="top-subtab-sub">prospect chats</span>
7042
7695
  </span>
7696
+ <span class="top-subtab" data-subtab="links" role="tab" aria-selected="false" title="Destination URLs across all posts, ranked by clicks. Homepage vs audience pages vs SEO pages.">
7697
+ <span class="top-subtab-icon" aria-hidden="true">\ud83d\udd17</span>
7698
+ <span class="top-subtab-label">Links</span>
7699
+ <span class="top-subtab-sub">destinations</span>
7700
+ </span>
7043
7701
  </div>
7044
7702
  <div class="top-controls">
7045
7703
  <input id="top-search" class="top-search" type="search" placeholder="Search posts\u2026" />
@@ -7141,6 +7799,9 @@ const HTML = `<!DOCTYPE html>
7141
7799
  <div id="top-dms-container" class="hidden">
7142
7800
  <div class="style-stats-empty">Loading\u2026</div>
7143
7801
  </div>
7802
+ <div id="top-links-container" class="hidden">
7803
+ <div class="style-stats-empty">Loading\u2026</div>
7804
+ </div>
7144
7805
  </div>
7145
7806
 
7146
7807
  <div class="content hidden" id="tab-logs">
@@ -7799,31 +8460,53 @@ function renderResult(run) {
7799
8460
  'salvaged <span style="color:var(--text);font-weight:600;">' + salvPrimary + '</span>' +
7800
8461
  salvBracket +
7801
8462
  '</span>';
7802
- const tooltip = 'searches: ' + searches +
7803
- ' / raw tweets: ' + raw +
7804
- ' / passed score-time cuts: ' + passed +
7805
- ' / dropped pre-score (already-posted or age>18h): ' + dropped +
7806
- ' / expired (delta<1 floor): ' + expired +
7807
- ' / above review cap (delta>=10, gates POST_LIMIT=3): ' + aboveFloor +
7808
- ' / posted: ' + posted +
7809
- ' / Phase 0 salvaged into this cycle: ' + salvAttempted +
7810
- ' (of which posted: ' + salvPosted + ')' +
7811
- ' / salvageable now (pool size for next cycle): ' + salvageableLive +
7812
- ' (+' + salvAdded + ' became salvageable / -' + salvDrained + ' drained this run)' +
7813
- ' / pending end-of-run: ' + queue +
7814
- ' (start: ' + queueStart + ', +' + qAdded + ' added, -' + qDrained + ' drained = ' +
7815
- qDrainedPosted + ' posted + ' + qDrainedExpired + ' expired + ' + qDrainedSkipped + ' skipped)' +
7816
- ' / pending right now (live): ' + pendingLive;
8463
+ // Tooltip is grouped by cycle phase so the funnel reads chronologically.
8464
+ // String.fromCharCode(10) sidesteps the outer HTML backtick template
8465
+ // that strips literal backslash-n escapes (see feedback_server_js_template_regex).
8466
+ // CSS .sa-tooltip white-space:pre-line turns these into line breaks.
8467
+ const NL = String.fromCharCode(10);
8468
+ const tooltip =
8469
+ 'Phase 0 (cleanup):' + NL +
8470
+ ' salvaged ' + salvAttempted + ': orphan rows adopted from prior dead cycles (' + salvPosted + ' posted this cycle)' + NL +
8471
+ ' pool for next cycle: ' + salvageableLive + ' salvageable (+' + salvAdded + ' / -' + salvDrained + ' this run)' + NL +
8472
+ NL +
8473
+ 'Phase 1 (scrape):' + NL +
8474
+ ' searches ' + searches + ': queries run' + NL +
8475
+ ' raw ' + raw + ': tweets returned' + NL +
8476
+ '• passed ' + passed + ': after dedup + age>18h cuts (' + dropped + ' dropped)' + NL +
8477
+ NL +
8478
+ 'Phase 2a (Δ re-score):' + NL +
8479
+ '• expired ' + expired + ': below Δ<1 likes floor' + NL +
8480
+ NL +
8481
+ 'Phase 2b (draft + post):' + NL +
8482
+ '• Δ≥10 ' + aboveFloor + ': crossed POST_LIMIT=3 review cap' + NL +
8483
+ '• posted ' + posted + ': shipped' + NL +
8484
+ '• failed ' + failed + ': post errors' + NL +
8485
+ NL +
8486
+ 'Pending end-of-run: ' + queue +
8487
+ ' (start ' + queueStart + ', +' + qAdded + ' / -' + qDrained + ' = ' +
8488
+ qDrainedPosted + ' posted, ' + qDrainedExpired + ' expired, ' + qDrainedSkipped + ' skipped)' + NL +
8489
+ 'Pending live: ' + pendingLive;
8490
+ // Pill order mirrors the tooltip story: salvaged (Phase 0 input) leads,
8491
+ // then Phase 1 funnel (searches, raw, passed), Phase 2a drop (expired),
8492
+ // Phase 2b decision and outcome (Δ≥10, posted, failed).
7817
8493
  return (
7818
8494
  '<span title="' + tooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
8495
+ queuePill +
7819
8496
  pill('searches', searches, searches > 0 ? 'var(--text)' : 'var(--muted)') +
7820
8497
  pill('raw', raw, raw > 0 ? 'var(--text)' : 'var(--muted)') +
7821
8498
  pill('passed', passed, passed > 0 ? '#22c55e' : 'var(--muted)') +
7822
8499
  pill('expired', expired, expired > 0 ? 'var(--text)' : 'var(--muted)') +
7823
8500
  pill('Δ≥10', aboveFloor, aboveFloor > 0 ? '#a78bfa' : 'var(--muted)') +
7824
8501
  pill('posted', posted, posted > 0 ? '#22c55e' : 'var(--muted)') +
7825
- queuePill +
7826
8502
  renderFailedPill() +
8503
+ (Array.isArray(r.projects_worked) && r.projects_worked.length
8504
+ ? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
8505
+ + 'projects '
8506
+ + '<span style="color:var(--text);font-weight:600;">'
8507
+ + r.projects_worked.join(', ')
8508
+ + '</span></span>'
8509
+ : '') +
7827
8510
  '</span>'
7828
8511
  );
7829
8512
  }
@@ -8097,6 +8780,26 @@ function renderResult(run) {
8097
8780
  // the old "posted=18216" pill was the total active-posts count from the
8098
8781
  // DB, which had nothing to do with what the run did. Render the real
8099
8782
  // per-run counters parsed out of the stats log instead.
8783
+ //
8784
+ // 2026-05-18 relabel: split the misleading single "updated" pill into
8785
+ // four explicit pills (scanned / checked / changed / views) so the
8786
+ // operator can read at a glance what the run actually did:
8787
+ //
8788
+ // scanned -> total rows considered (= polled + every flavor of skip)
8789
+ // skipped -> rows we deliberately did NOT poll (covered by Step 1
8790
+ // scrape or stable cooldown). Saves API calls.
8791
+ // checked -> rows we actually hit the platform API for this run.
8792
+ // changed -> subset of "checked" where any tracked metric moved.
8793
+ // Was the original intent of "updated" but the legacy
8794
+ // field also summed in the Step 1 view-scrape count.
8795
+ // views -> Step 1 scrape leg only: rows where the cheap profile-
8796
+ // page scrape wrote a fresh view count (Reddit) or
8797
+ // fxtwitter view (Twitter). Distinct from "changed".
8798
+ // replies -> per-reply rows refreshed (DM-rail follow-ups we made
8799
+ // on someone else thread, live in the "replies" table).
8800
+ //
8801
+ // Each pill carries data-tooltip so hovering surfaces the meaning
8802
+ // line-by-line via the global .sa-tooltip handler.
8100
8803
  if (run.job_type === 'stats') {
8101
8804
  const checked = r.checked || 0;
8102
8805
  const updated = r.updated || 0;
@@ -8106,19 +8809,71 @@ function renderResult(run) {
8106
8809
  const skipped = r.skipped || 0;
8107
8810
  const failed = r.failed || 0;
8108
8811
  const repliesRefreshed = r.replies_refreshed || 0;
8812
+ // New 2026-05-18 fields; fall back to derived values when the log line
8813
+ // pre-dates the relabel pass (so historical rows still render sanely).
8814
+ const scanned = r.scanned || (checked + skipped) || 0;
8815
+ const changed = r.changed || 0;
8816
+ const viewsRefreshed = r.views_refreshed || 0;
8109
8817
  if (!checked && !updated && !removed && !unavailable && !notFound &&
8110
- !skipped && !failed && !repliesRefreshed) {
8818
+ !skipped && !failed && !repliesRefreshed &&
8819
+ !scanned && !changed && !viewsRefreshed) {
8111
8820
  return '<span style="color:var(--muted);font-size:12px;">—</span>';
8112
8821
  }
8822
+ // Inline helper: pill with a data-tooltip attribute for per-pill hover
8823
+ // explanations. Plain pill() (above) has no tooltip slot; this is
8824
+ // local to stats-job rendering only.
8825
+ const tipPill = (label, n, color, tip) => {
8826
+ const tipEsc = (tip || '').replace(/"/g, '&quot;');
8827
+ return '<span data-tooltip="' + tipEsc + '" style="display:inline-block;' +
8828
+ 'margin-right:10px;font-size:12px;color:var(--muted);cursor:help;">' +
8829
+ label + ' <span style="color:' + (color || 'var(--text)') +
8830
+ ';font-weight:600;">' + n + '</span></span>';
8831
+ };
8113
8832
  return (
8114
- pill('checked', checked, 'var(--text)') +
8115
- pill('updated', updated, '#22c55e') +
8116
- (removed ? pill('removed', removed, '#eab308') : '') +
8117
- (unavailable ? pill('unavail', unavailable, '#eab308') : '') +
8118
- (notFound ? pill('not found', notFound, 'var(--muted)') : '') +
8119
- (skipped ? pill('skipped', skipped, 'var(--muted)') : '') +
8120
- (repliesRefreshed ? pill('replies', repliesRefreshed, '#3b82f6') : '') +
8121
- (failed ? pill('failed', failed, '#ef4444') : '')
8833
+ tipPill('scanned', scanned, 'var(--text)',
8834
+ 'Total rows the run considered (every active row in the relevant ' +
8835
+ 'platform tables). = checked + skipped + bypassed-as-fresh.') +
8836
+ (skipped ? tipPill('skipped', skipped, 'var(--muted)',
8837
+ 'Rows we deliberately did NOT poll this run. Two reasons: ' +
8838
+ '(1) already refreshed by the cheap scrape leg within the last 4h, ' +
8839
+ '(2) stable cooldown (2+ scans with no metric change AND older than ' +
8840
+ '3 days). Saves API calls; data is still current.') : '') +
8841
+ tipPill('checked', checked, 'var(--text)',
8842
+ 'Rows we actually hit the platform API for this run ' +
8843
+ '(Reddit old.reddit.com JSON / fxtwitter / LinkedIn activity feed). ' +
8844
+ 'Includes both successful polls and the ones that errored mid-fetch.') +
8845
+ tipPill('changed', changed, '#22c55e',
8846
+ 'Subset of CHECKED where any tracked metric (upvotes, ' +
8847
+ 'comments_count, views) actually moved since the prior scan. ' +
8848
+ 'The real-activity signal; everything else is no-op polling.') +
8849
+ (viewsRefreshed ? tipPill('views', viewsRefreshed, '#06b6d4',
8850
+ 'Rows where the cheap view-scrape leg wrote a fresh view count ' +
8851
+ 'this run. Reddit: Step 1 profile-page scrape (sees every ' +
8852
+ 'comment + thread on /user/<name>/). Twitter: built-in to the ' +
8853
+ 'fxtwitter call. Separate from CHANGED because views can ' +
8854
+ 'tick up without upvotes/comments moving.') : '') +
8855
+ (repliesRefreshed ? tipPill('replies', repliesRefreshed, '#3b82f6',
8856
+ 'Per-reply rows refreshed: comments we authored AS replies to ' +
8857
+ 'someone else reply to our original comment (the DM-rail ' +
8858
+ 'follow-up). Live in the replies table, not posts. ' +
8859
+ 'Reddit refreshes upvotes + reply-count via batch JSON API. ' +
8860
+ 'Twitter also refreshes views via fxtwitter.') : '') +
8861
+ (removed ? tipPill('removed', removed, '#eab308',
8862
+ 'Posts newly flagged deleted/removed this run. Reddit: comment ' +
8863
+ 'gone from thread JSON for 2+ consecutive scans (deletion_detect_' +
8864
+ 'count threshold). LinkedIn: post returned "Post unavailable".') : '') +
8865
+ (unavailable ? tipPill('unavail', unavailable, '#eab308',
8866
+ 'LinkedIn only: post explicitly returned a Post Unavailable ' +
8867
+ 'string. Subset of REMOVED; rendered as its own pill so an ' +
8868
+ 'operator can tell hard-deletion from rate-limit or network noise.') : '') +
8869
+ (notFound ? tipPill('not found', notFound, 'var(--muted)',
8870
+ 'LinkedIn only: post is still active on LinkedIn but our specific ' +
8871
+ 'comment couldn\'t be located on the activity feed (may have ' +
8872
+ 'aged off our visible recent-activity window).') : '') +
8873
+ (failed ? tipPill('failed', failed, '#ef4444',
8874
+ 'API errors during the run, broken down by category: 404 not_found, ' +
8875
+ 'rate-limited (429), empty / malformed response, or other / network. ' +
8876
+ 'Includes step-exit failures from the shell pipeline as well.') : '')
8122
8877
  );
8123
8878
  }
8124
8879
  // seo_expire (delete dead-weight pages): repurposes posted/skipped from the
@@ -8276,7 +9031,7 @@ function buildSeoDetailRows(run) {
8276
9031
  if (!details.length) return '';
8277
9032
  const subRows = details.map(d => {
8278
9033
  const cost = (typeof d.cost_usd === 'number' && d.cost_usd > 0)
8279
- ? fmtCostCell(d.cost_usd, d.cost_usd_orchestrator, d.cost_usd_estimated)
9034
+ ? fmtCostCell(d.cost_usd, d.cost_usd_orchestrator, d.cost_usd_estimated, d.cost_usd_subagent)
8280
9035
  : '<span style="color:var(--muted);">—</span>';
8281
9036
  const turns = (typeof d.num_turns === 'number' && d.num_turns > 0)
8282
9037
  ? d.num_turns
@@ -8322,6 +9077,85 @@ function buildSeoDetailRows(run) {
8322
9077
  );
8323
9078
  }
8324
9079
 
9080
+ // Cost cell for a Job History row. SDK-only mode (2026-05-15): the headline
9081
+ // total is the sum of orchestrator_cost_usd across the phases of this run.
9082
+ // Sessions whose wrapper didn't capture SDK cost contribute 0; the per-phase
9083
+ // breakdown surfaces a "missing SDK" count so the operator can spot pipelines
9084
+ // where real spend went unrecorded (the cost cell shows the smaller real
9085
+ // number, not an inflated estimate).
9086
+ function _jobHistoryCostCell(result) {
9087
+ const fmtLane = (v) => {
9088
+ if (v == null) return 'n/a';
9089
+ const n = Number(v);
9090
+ if (!isFinite(n)) return 'n/a';
9091
+ if (n === 0) return '$0';
9092
+ if (n < 0.01) return '$' + n.toFixed(4);
9093
+ return '$' + n.toFixed(4);
9094
+ };
9095
+ const bd = result.cost_breakdown;
9096
+ const orch = result.cost_usd_orchestrator != null ? Number(result.cost_usd_orchestrator) : null;
9097
+ const sub = result.cost_usd_subagent != null ? Number(result.cost_usd_subagent) : null;
9098
+ const est = result.cost_usd_estimated != null ? Number(result.cost_usd_estimated) : null;
9099
+ const sessionsAll = bd ? Number(bd.sessions) || 0 : 0;
9100
+ const sessionsWithSdk = bd ? Number(bd.sessions_with_sdk) || 0 : 0;
9101
+ const sessionsMissing = Math.max(0, sessionsAll - sessionsWithSdk);
9102
+ const totalForDisplay = orch != null ? orch : 0;
9103
+ // Header value: "n/a" when no SDK data captured for any of the run's
9104
+ // sessions, otherwise the orchestrator sum (with a "(N missing SDK)"
9105
+ // hint inline when partial).
9106
+ let headerHtml;
9107
+ if (sessionsAll === 0) {
9108
+ headerHtml = '<span style="color:var(--text-very-faint);">n/a</span>';
9109
+ } else if (sessionsWithSdk === 0) {
9110
+ headerHtml = '<span style="color:var(--text-very-faint);">n/a</span>';
9111
+ } else if (sessionsMissing > 0) {
9112
+ headerHtml = fmtCost(totalForDisplay) +
9113
+ ' <span style="color:#eab308;font-size:11px;">(' + sessionsMissing + ' missing)</span>';
9114
+ } else {
9115
+ headerHtml = fmtCost(totalForDisplay);
9116
+ }
9117
+ const lines = [
9118
+ 'Cost (SDK orchestrator): ' + (sessionsWithSdk > 0 ? fmtLane(orch) : 'n/a'),
9119
+ ];
9120
+ if (sessionsAll > 0) {
9121
+ lines.push(' Sessions: ' + sessionsAll +
9122
+ ' · with SDK data: ' + sessionsWithSdk +
9123
+ ' · missing SDK: ' + sessionsMissing);
9124
+ }
9125
+ if (bd && Array.isArray(bd.phases) && bd.phases.length) {
9126
+ lines.push('');
9127
+ lines.push('Per-phase (claude_sessions.script grouping):');
9128
+ const shown = bd.phases.slice(0, 10);
9129
+ for (const p of shown) {
9130
+ const missing = (p.sessions_missing_sdk && p.sessions_missing_sdk > 0)
9131
+ ? (' [' + p.sessions_missing_sdk + ' missing SDK]')
9132
+ : '';
9133
+ const orchVal = (p.sessions_with_sdk && p.sessions_with_sdk > 0)
9134
+ ? fmtLane(p.orch)
9135
+ : 'n/a';
9136
+ lines.push(' ' + (p.phase || '(unknown)') + ' x' + p.sessions +
9137
+ ' ' + orchVal + missing);
9138
+ }
9139
+ if (bd.phases.length > shown.length) {
9140
+ lines.push(' …(' + (bd.phases.length - shown.length) + ' more)');
9141
+ }
9142
+ }
9143
+ if (typeof result.cost_usd_from_log === 'number') {
9144
+ lines.push('');
9145
+ lines.push('Wrapper shell-log value: ' + fmtLane(result.cost_usd_from_log));
9146
+ }
9147
+ lines.push('');
9148
+ lines.push('Diagnostic-only (local pricing estimate, not actual billing):');
9149
+ lines.push(' Transcript estimate: ' + fmtLane(est));
9150
+ lines.push(' Subagent (est): ' + fmtLane(sub));
9151
+ lines.push('');
9152
+ lines.push('SDK-only mode: shows orchestrator_cost_usd captured by the SDK result event. "missing SDK" = wrapper script didn\\'t pass --output-format json to claude, so no result event = no cost data recorded. Patch the wrapper to fix.');
9153
+ const tip = lines.join('\\n');
9154
+ return '<span data-tooltip="' + escapeHtml(tip) +
9155
+ '" style="cursor:help;border-bottom:1px dotted var(--text-muted);">' +
9156
+ headerHtml + '</span>';
9157
+ }
9158
+
8325
9159
  // Stable identity for a job-history row across polls. (script, started_at)
8326
9160
  // is unique in practice; pid is appended as a tiebreaker for the rare case
8327
9161
  // where two parallel fires of the same script land in the same second.
@@ -8350,8 +9184,16 @@ function _jobHistoryRowSig(r) {
8350
9184
  ].join('|');
8351
9185
  }
8352
9186
  function _buildJobHistoryRowGroup(r, idx) {
8353
- const cost = r.result && r.result.cost_usd;
8354
- const costCell = cost ? fmtCost(cost) : '<span style="color:var(--muted);">—</span>';
9187
+ // SDK-only mode: render the cost cell whenever the run produced any
9188
+ // claude_sessions rows (cost_breakdown is attached), even if the SDK lane
9189
+ // is NULL across all of them — the tooltip surfaces "missing SDK" so the
9190
+ // operator sees pipelines whose wrappers haven't been patched yet.
9191
+ const hasBreakdown = r.result && r.result.cost_breakdown
9192
+ && Number(r.result.cost_breakdown.sessions) > 0;
9193
+ const hasLogCost = r.result && typeof r.result.cost_usd === 'number';
9194
+ const costCell = (hasBreakdown || hasLogCost)
9195
+ ? _jobHistoryCostCell(r.result)
9196
+ : '<span style="color:var(--muted);">—</span>';
8355
9197
  const hasDetails = Array.isArray(r.details) && r.details.length;
8356
9198
  const caret = hasDetails
8357
9199
  ? '<span class="sa-job-caret" style="display:inline-block;width:12px;color:var(--muted);cursor:pointer;user-select:none;transition:transform 0.15s ease;">&#9656;</span> '
@@ -8916,8 +9758,12 @@ async function saveSettings() {
8916
9758
  }
8917
9759
 
8918
9760
  // Activity tab
8919
- const EVENT_TYPES = ['posted_thread', 'posted_comment', 'replied', 'skipped', 'mention', 'dm_sent', 'dm_reply_sent', 'page_published_serp', 'page_published_gsc', 'page_published_reddit', 'page_published_top', 'page_published_top_post', 'page_published_roundup', 'page_improved', 'page_expired', 'resurrected'];
8920
- const EVENT_LABELS = { posted_thread: 'thread posted', posted_comment: 'comment posted', replied: 'engage replied', skipped: 'engage skipped', mention: 'mention', dm_sent: 'dm sent', dm_reply_sent: 'dm reply', page_published_serp: 'page (serp)', page_published_gsc: 'page (gsc)', page_published_reddit: 'page (reddit)', page_published_top: 'page (top)', page_published_top_post: 'page (top post)', page_published_roundup: 'page (roundup)', page_improved: 'page (improved)', page_expired: 'page expired', resurrected: 'resurrected' };
9761
+ // Page-generation event types. Folded into a single 'pages_generated' card
9762
+ // in renderActivityStats (with per-pipeline breakdown in the body + tooltip).
9763
+ // SQL still emits each subtype so the breakdown is faithful.
9764
+ const PAGE_GEN_EVENT_TYPES = ['page_published_twitter', 'page_published_gsc', 'page_published_reddit', 'page_published_top', 'page_published_top_post', 'page_published_roundup', 'page_published_misc', 'page_improved'];
9765
+ const EVENT_TYPES = ['posted_thread', 'posted_comment', 'replied', 'skipped', 'mention', 'dm_sent', 'dm_reply_sent', ...PAGE_GEN_EVENT_TYPES, 'page_expired', 'resurrected'];
9766
+ const EVENT_LABELS = { posted_thread: 'thread posted', posted_comment: 'comment posted', replied: 'engage replied', skipped: 'engage skipped', mention: 'mention', dm_sent: 'dm sent', dm_reply_sent: 'dm reply', page_published_twitter: 'page (twitter)', page_published_gsc: 'page (gsc)', page_published_reddit: 'page (reddit)', page_published_top: 'page (top)', page_published_top_post: 'page (top post)', page_published_roundup: 'page (roundup)', page_published_misc: 'page (misc)', page_improved: 'page (improved)', page_expired: 'page expired', resurrected: 'resurrected', pages_generated: 'pages generated' };
8921
9767
  const EVENT_DESCRIPTIONS = {
8922
9768
  posted_thread: 'Original thread the bot published (Post Threads job): a new top-level Reddit submission, tweet/X post, LinkedIn post, etc. Identified by thread_url = our_url AND thread_author is empty or matches our_account.',
8923
9769
  posted_comment: 'Comment the bot left on someone else’s thread (Post Comments job): a Reddit/Twitter/LinkedIn/Moltbook/GitHub reply where thread_url ≠ our_url, or where thread_url = our_url but thread_author is someone else (LinkedIn comment job stores the parent URL in both columns).',
@@ -8926,13 +9772,15 @@ const EVENT_DESCRIPTIONS = {
8926
9772
  mention: 'Someone mentioned one of our products on a tracked platform. Detection only, no engagement action.',
8927
9773
  dm_sent: 'New direct-message conversation the bot started with a prospect.',
8928
9774
  dm_reply_sent: 'Follow-up message sent inside an existing DM conversation.',
8929
- page_published_serp: 'SEO landing page generated from the SERP pipeline (based on ranked search results for target keywords).',
9775
+ pages_generated: 'Total SEO/landing pages produced across every pipeline in this window. Hover the breakdown chips for the per-pipeline counts. The standalone SERP pipeline was unloaded 2026-04-17; any page tagged "twitter" came from the Twitter cycle page-gen A/B lane (twitter_gen_links.py), not a dedicated SERP run.',
9776
+ page_published_twitter: 'SEO landing page generated by the Twitter cycle page-gen A/B lane (twitter_gen_links.py -> generate_page.py --trigger twitter). Source rows have seo_keywords.source=twitter. This is what the legacy "SERP SEO" pill was actually counting after the standalone SERP pipeline was unloaded on 2026-04-17.',
8930
9777
  page_published_gsc: 'SEO page generated from a Google Search Console query the site already gets impressions for.',
8931
9778
  page_published_reddit: 'SEO page generated from a high-intent Reddit thread.',
8932
- page_published_top: 'SEO page generated for a top-of-funnel ranking opportunity.',
9779
+ page_published_top: 'SEO page generated for a top-of-funnel ranking opportunity (top_pages pipeline).',
8933
9780
  page_published_top_post: 'SEO page generated retroactively for a viral social-autoposter post (>=10k views, last 7d) whose link still points at the homepage. Pipeline also mounts a NewsStrip on the homepage routing existing organic clickthroughs to the new /t/ page.',
8934
9781
  page_published_roundup: 'Roundup or list-style SEO page (comparisons, best-of, alternatives).',
8935
- page_improved: 'Existing SEO page that was updated or rewritten to improve rankings.',
9782
+ page_published_misc: 'SEO page from a legacy or edge source (existing_page, suggestion:*, competitor:*, topic_template, feature_template, client_blog, etc). Mostly historical; near-zero new volume in 2026-05.',
9783
+ page_improved: 'Existing SEO page that was updated or rewritten to improve rankings (seo_page_improvements table).',
8936
9784
  page_expired: 'SEO page deleted by the daily expire pipeline because it had zero clicks in the last 30 days. The on-disk source file was removed; Next.js now returns 404 for the URL. Logged for audit/revert in seo_expired_pages.',
8937
9785
  resurrected: 'Previously archived or unavailable item brought back into rotation (e.g., a removed post restored after reappearing).',
8938
9786
  };
@@ -8944,7 +9792,10 @@ const ACTIVITY_CAMPAIGN_ORGANIC = '(organic)';
8944
9792
  let _activitySeen = new Set();
8945
9793
  let _activityFirstLoad = true;
8946
9794
  // Activity-tab filters/sort/search are persisted across reloads.
8947
- let _activityTypeFilter = saLoadSet('sa.activity.typeFilter.v2', EVENT_TYPES);
9795
+ // Bumped v2 -> v3 on 2026-05-16 when page_published_serp was split into
9796
+ // page_published_twitter + page_published_misc; old saved Sets did not
9797
+ // include the new types, so they'd silently disappear from the feed.
9798
+ let _activityTypeFilter = saLoadSet('sa.activity.typeFilter.v3', EVENT_TYPES);
8948
9799
  let _activityPlatformFilter = saLoadSet('sa.activity.platformFilter.v1', ACTIVITY_PLATFORMS);
8949
9800
  let _activityProjectFilter = saLoadSet('sa.activity.projectFilter.v1', []);
8950
9801
  let _activityKnownProjects = saLoad('sa.activity.knownProjects.v1', []);
@@ -9042,7 +9893,7 @@ function buildActivityFilters() {
9042
9893
  var added;
9043
9894
  if (_activityTypeFilter.has(t)) { _activityTypeFilter.delete(t); el.classList.remove('active'); added = false; }
9044
9895
  else { _activityTypeFilter.add(t); el.classList.add('active'); added = true; }
9045
- saSaveSet('sa.activity.typeFilter.v2', _activityTypeFilter);
9896
+ saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
9046
9897
  try { window.posthog && window.posthog.capture('filter_toggle', { table: 'activity', dimension: 'type', value: t, action: added ? 'add' : 'remove' }); } catch (er) {}
9047
9898
  _activityPage = 0;
9048
9899
  renderActivity(_lastActivityEvents || []);
@@ -9095,11 +9946,11 @@ function buildActivityFilters() {
9095
9946
  if (a === 'type-all') {
9096
9947
  _activityTypeFilter = new Set(EVENT_TYPES);
9097
9948
  tEl.querySelectorAll('[data-type]').forEach(c => c.classList.add('active'));
9098
- saSaveSet('sa.activity.typeFilter.v2', _activityTypeFilter);
9949
+ saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
9099
9950
  } else if (a === 'type-none') {
9100
9951
  _activityTypeFilter = new Set();
9101
9952
  tEl.querySelectorAll('[data-type]').forEach(c => c.classList.remove('active'));
9102
- saSaveSet('sa.activity.typeFilter.v2', _activityTypeFilter);
9953
+ saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
9103
9954
  } else if (a === 'platform-all') {
9104
9955
  _activityPlatformFilter = new Set(ACTIVITY_PLATFORMS);
9105
9956
  pEl.querySelectorAll('[data-platform]').forEach(c => c.classList.add('active'));
@@ -9201,19 +10052,21 @@ function fmtCost(c) {
9201
10052
  //
9202
10053
  // Args (no backticks anywhere; this whole helper sits inside the dashboard
9203
10054
  // HTML template literal, see feedback_server_js_template_regex memory):
9204
- // displayed value rendered in the cell. Already prefers SDK, falls
9205
- // back to estimate. Source of truth for the text.
10055
+ // displayed value rendered in the cell. Total = COALESCE(orch,
10056
+ // estimate) + subagent. Source of truth for the text.
9206
10057
  // orchestrator native SDK orchestrator cost (claude_sessions.
9207
10058
  // orchestrator_cost_usd, captured from streamRes.
9208
10059
  // total_cost_usd). Authoritative for orchestrator billing
9209
10060
  // but EXCLUDES Task subagent costs (anthropics/claude-code
9210
- // issue #43945).
9211
- // estimated manual transcript-derived estimate using local pricing
9212
- // tables (claude_sessions.total_cost_usd, written by
10061
+ // issue #43945). Subagent is now folded in via the 4th arg.
10062
+ // estimated manual transcript-derived estimate of orchestrator turns
10063
+ // only (claude_sessions.total_cost_usd, written by
9213
10064
  // log_claude_session.py).
9214
- function fmtCostCell(displayed, orchestrator, estimated) {
9215
- const text = fmtCost(displayed);
9216
- if (text === '') return '';
10065
+ // subagent Task/Agent subagent cost from sidechain transcripts +
10066
+ // sibling subagents/*.jsonl files (claude_sessions.
10067
+ // subagent_cost_usd). Added on top of the orch/estimate
10068
+ // lane to form the displayed total.
10069
+ function fmtCostCell(displayed, orchestrator, estimated, subagent) {
9217
10070
  const fmtLane = (v) => {
9218
10071
  if (v == null) return 'n/a';
9219
10072
  const n = Number(v);
@@ -9222,13 +10075,23 @@ function fmtCostCell(displayed, orchestrator, estimated) {
9222
10075
  if (n < 0.01) return '$' + n.toFixed(4);
9223
10076
  return '$' + n.toFixed(4);
9224
10077
  };
10078
+ // SDK-only mode (2026-05-15): the displayed value is orchestrator_cost_usd
10079
+ // alone. When that's NULL we render "n/a" — not $0, since the session DID
10080
+ // spend money but the wrapper didn't capture --orchestrator-cost-usd.
10081
+ // Estimate and subagent are diagnostic-only (computed from local pricing
10082
+ // table; not actual billing data).
10083
+ const hasOrch = orchestrator != null && Number.isFinite(Number(orchestrator));
10084
+ const text = hasOrch
10085
+ ? fmtCost(Number(orchestrator))
10086
+ : '<span style="color:var(--text-very-faint);">n/a</span>';
9225
10087
  const lines = [
9226
- 'Orchestrator (SDK): ' + fmtLane(orchestrator),
9227
- 'Estimated (transcript): ' + fmtLane(estimated),
10088
+ 'Cost (SDK orchestrator): ' + fmtLane(orchestrator),
9228
10089
  '',
9229
- 'Displayed value prefers the SDK orchestrator cost (native streamRes.total_cost_usd, matches Anthropic billing for the orchestrator session) and falls back to the manual transcript-derived estimate when the SDK value is unavailable.',
10090
+ 'Diagnostic-only (not actual billing):',
10091
+ ' Transcript estimate: ' + fmtLane(estimated),
10092
+ ' Subagent (est): ' + fmtLane(subagent),
9230
10093
  '',
9231
- 'Note: orchestrator cost EXCLUDES Task subagent spend (anthropics/claude-code #43945). The estimate uses our local pricing table over the parent transcript only and has the same exclusion.',
10094
+ 'Displays Anthropic-reported orchestrator_cost_usd only. "n/a" means the wrapper didn\\'t capture the SDK cost (no --output-format json on the claude call). Transcript estimate and subagent figures are computed locally from a hardcoded pricing table informational, not billing-accurate on subscription plans.',
9232
10095
  ];
9233
10096
  const tip = lines.join('\\n');
9234
10097
  return '<span data-tooltip="' + escapeHtml(tip) +
@@ -9525,22 +10388,31 @@ function currentStatsProject() {
9525
10388
  const row = document.getElementById('style-stats-project-pills');
9526
10389
  return (row && row.dataset.selected) || 'all';
9527
10390
  }
9528
- function reloadStatsTabSections() {
9529
- loadActivityStats();
9530
- loadCohortStats();
9531
- loadStyleStats();
10391
+ // Sets body.sa-stats-busy while the batch of stats fetches kicked off by a
10392
+ // single filter change is in flight. CSS uses the class to disable the four
10393
+ // pill rows (status window, stats window, style-stats platform/project) so
10394
+ // users can't queue overlapping reloads across rapid pill clicks.
10395
+ async function reloadStatsTabSections() {
10396
+ document.body.classList.add('sa-stats-busy');
10397
+ const pending = [
10398
+ loadActivityStats(),
10399
+ loadCohortStats(),
10400
+ loadStyleStats(),
10401
+ ];
9532
10402
  // daily-metrics chart now lives on its own Trends tab with its own filter
9533
10403
  // bar; intentionally NOT reloaded on stats-tab window/platform/project
9534
10404
  // of the filter bar.
9535
10405
  const funnelEl = document.getElementById('funnel-stats');
9536
10406
  if (funnelEl && funnelEl.open) {
9537
10407
  if (_lastFunnelPayload) renderFunnelStats(_lastFunnelPayload);
9538
- else loadFunnelStats(true);
10408
+ else pending.push(loadFunnelStats(true));
9539
10409
  }
9540
10410
  const dmEl = document.getElementById('dm-stats');
9541
- if (dmEl && dmEl.open) loadDmStats(true);
10411
+ if (dmEl && dmEl.open) pending.push(loadDmStats(true));
9542
10412
  const sqEl = document.getElementById('search-queries-stats');
9543
- if (sqEl && sqEl.open) loadSearchQueriesStats(true);
10413
+ if (sqEl && sqEl.open) pending.push(loadSearchQueriesStats(true));
10414
+ try { await Promise.allSettled(pending); }
10415
+ finally { document.body.classList.remove('sa-stats-busy'); }
9544
10416
  }
9545
10417
  function syncStatsHeadings() {
9546
10418
  const win = currentStatsWindow();
@@ -9585,7 +10457,63 @@ function renderActivityStats(payload) {
9585
10457
  grandTotal += n;
9586
10458
  });
9587
10459
  if (totalEl) totalEl.textContent = grandTotal + ' events in ' + currentStatsWindow().labelLong;
9588
- grid.innerHTML = EVENT_TYPES.map(t => {
10460
+ // Fold all page-generation subtypes into a single synthetic 'pages_generated'
10461
+ // card so the grid stops looking like six near-empty SEO cells. The breakdown
10462
+ // row inside the card lists each pipeline + count chip; the hover tooltip on
10463
+ // each chip shows the long description for that pipeline. Card-level "i"
10464
+ // icon shows the umbrella tooltip.
10465
+ const pagesBucket = { total: 0, subtypes: {} };
10466
+ PAGE_GEN_EVENT_TYPES.forEach(t => {
10467
+ const b = byType[t];
10468
+ if (!b) return;
10469
+ pagesBucket.total += b.total;
10470
+ if (b.total > 0) pagesBucket.subtypes[t] = b.total;
10471
+ });
10472
+ const PAGE_GEN_CHIP_LABELS = {
10473
+ page_published_twitter: 'twitter',
10474
+ page_published_gsc: 'gsc',
10475
+ page_published_reddit: 'reddit',
10476
+ page_published_top: 'top',
10477
+ page_published_top_post: 'top post',
10478
+ page_published_roundup: 'roundup',
10479
+ page_published_misc: 'misc',
10480
+ page_improved: 'improved',
10481
+ };
10482
+ // Render order: keep PAGE_GEN_EVENT_TYPES order, then drop the non-page tail
10483
+ // cards (page_expired, resurrected) at the end. Suppress individual page_*
10484
+ // cards since we render the umbrella card instead.
10485
+ const renderOrder = [];
10486
+ EVENT_TYPES.forEach(t => {
10487
+ if (PAGE_GEN_EVENT_TYPES.includes(t)) return; // folded into umbrella
10488
+ renderOrder.push(t);
10489
+ });
10490
+ // Insert the umbrella where the first page card used to live (after dm_reply_sent).
10491
+ const dmIdx = renderOrder.indexOf('dm_reply_sent');
10492
+ if (dmIdx >= 0) renderOrder.splice(dmIdx + 1, 0, '__pages_generated__');
10493
+ else renderOrder.push('__pages_generated__');
10494
+
10495
+ grid.innerHTML = renderOrder.map(t => {
10496
+ if (t === '__pages_generated__') {
10497
+ const total = pagesBucket.total;
10498
+ const subtypes = Object.keys(pagesBucket.subtypes).sort((a, b) => pagesBucket.subtypes[b] - pagesBucket.subtypes[a]);
10499
+ const chips = subtypes.length
10500
+ ? subtypes.map(st => {
10501
+ const lab = PAGE_GEN_CHIP_LABELS[st] || st.replace(/^page_published_/, '');
10502
+ const desc = EVENT_DESCRIPTIONS[st] || '';
10503
+ const titleAttr = desc ? ' data-tooltip="' + escapeHtml(desc) + '"' : '';
10504
+ return '<span class="stat-plat"' + titleAttr + '><span class="stat-plat-text">' + escapeHtml(lab) + '</span><span class="stat-plat-count">' + pagesBucket.subtypes[st] + '</span></span>';
10505
+ }).join('')
10506
+ : '<span style="color:var(--text-very-faint);">\u2014</span>';
10507
+ const umbrellaDesc = EVENT_DESCRIPTIONS.pages_generated || '';
10508
+ const infoIcon = '<span class="stat-card-info" data-tooltip="' + escapeHtml(umbrellaDesc) + '" aria-label="' + escapeHtml(umbrellaDesc) + '">i</span>';
10509
+ return '<div class="stat-card ev-pages-generated' + (total === 0 ? ' zero' : '') + '">' +
10510
+ '<div class="stat-card-head">' +
10511
+ '<span class="stat-card-label">pages generated' + infoIcon + '</span>' +
10512
+ '<span class="stat-card-count">' + total + '</span>' +
10513
+ '</div>' +
10514
+ '<div class="stat-card-breakdown">' + chips + '</div>' +
10515
+ '</div>';
10516
+ }
9589
10517
  const bucket = byType[t];
9590
10518
  const total = bucket.total;
9591
10519
  const plats = Object.keys(bucket.platforms).sort((a, b) => bucket.platforms[b] - bucket.platforms[a]);
@@ -9612,6 +10540,13 @@ function renderActivityStats(payload) {
9612
10540
  }
9613
10541
 
9614
10542
  async function loadActivityStats() {
10543
+ // Immediate visual feedback on filter change. Without this the previously
10544
+ // rendered grid sits frozen until the 9-way UNION returns; on a cold cache
10545
+ // miss that's a couple seconds with zero indication anything is happening.
10546
+ const grid = document.getElementById('stats-grid');
10547
+ const totalEl = document.getElementById('stats-total');
10548
+ if (grid) grid.classList.add('is-loading');
10549
+ if (totalEl) totalEl.textContent = 'loading…';
9615
10550
  try {
9616
10551
  const hours = currentStatsWindow().hours;
9617
10552
  const plat = currentStatsPlatform();
@@ -9622,7 +10557,9 @@ async function loadActivityStats() {
9622
10557
  const res = await fetch('/api/activity/stats?' + params.join('&'));
9623
10558
  const data = await res.json();
9624
10559
  renderActivityStats(data);
9625
- } catch {}
10560
+ } catch {} finally {
10561
+ if (grid) grid.classList.remove('is-loading');
10562
+ }
9626
10563
  }
9627
10564
 
9628
10565
  // Combined daily-metrics line chart (Trends tab). Fetches 4 endpoints (2
@@ -9642,6 +10579,14 @@ async function loadActivityStats() {
9642
10579
  // to a capture day; expect those lines to sit at 0 until at least two
9643
10580
  // consecutive days of snapshots have accumulated per post.
9644
10581
  let DAILY_METRICS = [
10582
+ // Output volume: posts we made per day, split by type. 'threads' counts
10583
+ // posts where we authored the thread itself; 'comments_made' counts posts
10584
+ // where we engaged on someone else's thread. Both come from the same
10585
+ // /api/posts/per-day endpoint (server returns threads_made + comments_made
10586
+ // alongside posts_made). 'comments_made' is intentionally distinct from
10587
+ // the 'comments' pill below, which counts comments EARNED on our posts.
10588
+ { id: 'threads', label: 'Threads', color: '#a855f7', endpoint: '/api/posts/per-day', valueKey: 'threads_made', platformAware: true },
10589
+ { id: 'comments_made', label: 'Comments Made', color: '#d946ef', endpoint: '/api/posts/per-day', valueKey: 'comments_made', platformAware: true },
9645
10590
  { id: 'views', label: 'Views', color: '#6366f1', endpoint: '/api/views/per-day', valueKey: 'views_gained', platformAware: true },
9646
10591
  { id: 'upvotes', label: 'Upvotes', color: '#f97316', endpoint: '/api/upvotes/per-day', valueKey: 'upvotes_gained', platformAware: true },
9647
10592
  { id: 'comments', label: 'Comments', color: '#14b8a6', endpoint: '/api/comments/per-day', valueKey: 'comments_gained', platformAware: true },
@@ -9989,6 +10934,11 @@ function renderDailyMetrics() {
9989
10934
  // no new fetch needed. Values are percentages (0-100), formatted to one
9990
10935
  // decimal place; days with views=0 are dropped (ratios are undefined).
9991
10936
  let RATIO_METRICS = [
10937
+ // Views per post: how many views a post earns on average per day in the
10938
+ // window. Numerator is views_gained that day; denominator is posts_made
10939
+ // that day. format='count' renders as plain K/M numbers (e.g. "1.2K")
10940
+ // since the value is a count, not a percentage or dollar figure.
10941
+ { id: 'views_per_post', label: 'Views / Post', color: '#a855f7', numerator: 'views', denominator: 'posts', format: 'count', scaleFactor: 1 },
9992
10942
  { id: 'upvotes_per_view', label: 'Upvotes / Views', color: '#f97316', numerator: 'upvotes', denominator: 'views', format: 'pct', scaleFactor: 100 },
9993
10943
  { id: 'comments_per_view', label: 'Comments / Views', color: '#14b8a6', numerator: 'comments', denominator: 'views', format: 'pct', scaleFactor: 100 },
9994
10944
  { id: 'clicks_per_view', label: 'Clicks / Views', color: '#0ea5e9', numerator: 'clicks', denominator: 'views', format: 'pct', scaleFactor: 100 },
@@ -10007,11 +10957,11 @@ let RATIO_METRICS = [
10007
10957
  { id: 'cost_per_kviews', label: 'Cost / 1k Views', color: '#dc2626', numerator: 'cost', denominator: 'views', format: 'usd', scaleFactor: 1000, adminOnly: true },
10008
10958
  { id: 'cost_per_kvisitors', label: 'Cost / 1k Visitors', color: '#7c3aed', numerator: 'cost', denominator: 'pageviews', format: 'usd', scaleFactor: 1000, adminOnly: true },
10009
10959
  ];
10010
- const RATIO_METRICS_DEFAULTS = ['upvotes_per_view', 'comments_per_view', 'clicks_per_view', 'email_signups_per_session', 'schedule_clicks_per_session', 'get_started_per_session', 'cost_per_kviews', 'cost_per_kvisitors'];
10011
- // .v2: ratio set expanded to include cost_per_kviews + cost_per_kvisitors.
10012
- // Bumping the storage key seeds the new defaults exactly once so existing
10013
- // users see the new ratios pre-selected the next time they open Trends.
10014
- const RATIO_METRICS_STORAGE_KEY = 'ratioMetricsActive.v2';
10960
+ const RATIO_METRICS_DEFAULTS = ['views_per_post', 'upvotes_per_view', 'comments_per_view', 'clicks_per_view', 'email_signups_per_session', 'schedule_clicks_per_session', 'get_started_per_session', 'cost_per_kviews', 'cost_per_kvisitors'];
10961
+ // .v3: ratio set expanded to include views_per_post at the head. Bumping
10962
+ // the storage key seeds the new defaults exactly once so existing users
10963
+ // see the new ratio pre-selected the next time they open Trends.
10964
+ const RATIO_METRICS_STORAGE_KEY = 'ratioMetricsActive.v3';
10015
10965
  let _ratioMetricsActive = null;
10016
10966
  function _loadRatioMetricsActive() {
10017
10967
  if (_ratioMetricsActive) return _ratioMetricsActive;
@@ -10036,6 +10986,12 @@ function _fmtPct(n) {
10036
10986
  function _fmtForRatio(r, n) {
10037
10987
  if (n == null || !isFinite(n)) return '—';
10038
10988
  if (r && r.format === 'usd') return _fmtUsd(n);
10989
+ if (r && r.format === 'count') {
10990
+ // Whole-number K/M counts (no decimals) for ratios like Views / Post.
10991
+ if (n >= 1_000_000) return Math.round(n / 1_000_000) + 'M';
10992
+ if (n >= 1_000) return Math.round(n / 1_000) + 'K';
10993
+ return String(Math.round(n));
10994
+ }
10039
10995
  return _fmtPct(n);
10040
10996
  }
10041
10997
 
@@ -10203,10 +11159,13 @@ function renderRatioMetrics() {
10203
11159
  const day = days[idx];
10204
11160
  const rows = visible.map(r => {
10205
11161
  const v = ratioSeries[r.id][day];
10206
- // "no views" / "no visitors" depending on the denominator. Cost
10207
- // ratios use the right unit so the empty-day message matches the
10208
- // ratio's meaning ("no views" for cost_per_kviews, etc.).
10209
- const emptyLabel = (r.denominator === 'pageviews') ? 'no visitors' : 'no views';
11162
+ // "no views" / "no visitors" / "no posts" depending on the
11163
+ // denominator. Cost ratios use the right unit so the empty-day
11164
+ // message matches the ratio's meaning ("no views" for
11165
+ // cost_per_kviews, etc.).
11166
+ const emptyLabel = (r.denominator === 'pageviews') ? 'no visitors'
11167
+ : (r.denominator === 'posts') ? 'no posts'
11168
+ : 'no views';
10210
11169
  const display = (v == null || !isFinite(v)) ? emptyLabel : _fmtForRatio(r, v);
10211
11170
  return '<div class="tt-row"><span class="swatch" style="background:' + r.color + ';"></span>' +
10212
11171
  '<span>' + escapeHtml(r.label) + '</span>' +
@@ -10355,7 +11314,7 @@ async function loadDailyMetrics() {
10355
11314
  const qsProj = projectOnlyParams.join('&');
10356
11315
  try {
10357
11316
  const costAvail = window.SA_IS_ADMIN !== false;
10358
- const [views, upvotes, comments, clicks, bookings, funnel, cost] = await Promise.all([
11317
+ const [views, upvotes, comments, clicks, bookings, funnel, cost, posts] = await Promise.all([
10359
11318
  fetchOne('/api/views/per-day?' + qsAware),
10360
11319
  fetchOne('/api/upvotes/per-day?' + qsAware),
10361
11320
  fetchOne('/api/comments/per-day?' + qsAware),
@@ -10363,8 +11322,9 @@ async function loadDailyMetrics() {
10363
11322
  fetchOne('/api/bookings/per-day?' + qsProj),
10364
11323
  fetchOne('/api/funnel/per-day?' + qsProj),
10365
11324
  costAvail ? fetchOne('/api/cost/per-day?' + qsAware) : Promise.resolve({ rows: [], failed: false }),
11325
+ fetchOne('/api/posts/per-day?' + qsAware),
10366
11326
  ]);
10367
- const allFailed = [views, upvotes, comments, clicks, bookings, funnel, cost].every(r => r.failed);
11327
+ const allFailed = [views, upvotes, comments, clicks, bookings, funnel, cost, posts].every(r => r.failed);
10368
11328
  if (allFailed) {
10369
11329
  if (chartEl) chartEl.innerHTML = '<div class="views-chart-empty">Unable to load daily metrics (all endpoints failed).</div>';
10370
11330
  return;
@@ -10380,6 +11340,9 @@ async function loadDailyMetrics() {
10380
11340
  intoSeries('clicks', clicks.rows, 'clicks_gained');
10381
11341
  intoSeries('bookings', bookings.rows, 'bookings_gained');
10382
11342
  intoSeries('cost', cost.rows, 'cost_usd');
11343
+ intoSeries('posts', posts.rows, 'posts_made');
11344
+ intoSeries('threads', posts.rows, 'threads_made');
11345
+ intoSeries('comments_made', posts.rows, 'comments_made');
10383
11346
  DAILY_METRICS.filter(m => m.funnel).forEach(m => {
10384
11347
  intoSeries(m.id, funnel.rows, m.valueKey);
10385
11348
  });
@@ -10402,7 +11365,7 @@ async function loadDailyMetrics() {
10402
11365
  // Stash a list of failed endpoints so renderDailyMetrics can surface a
10403
11366
  // small "(N timed out)" hint in the status pill rather than silently
10404
11367
  // showing flat zeros for those series.
10405
- const fetchResults = { views, upvotes, comments, clicks, bookings, funnel, cost };
11368
+ const fetchResults = { views, upvotes, comments, clicks, bookings, funnel, cost, posts };
10406
11369
  _dailyMetricsFailed = Object.keys(fetchResults)
10407
11370
  .filter(k => fetchResults[k].failed)
10408
11371
  .map(k => ({ key: k, timedOut: !!fetchResults[k].timedOut }));
@@ -10899,6 +11862,10 @@ function getStyleMeta() {
10899
11862
  }
10900
11863
 
10901
11864
  async function loadStyleStats() {
11865
+ const body = document.getElementById('style-stats-body');
11866
+ const totalEl = document.getElementById('style-stats-total');
11867
+ if (body) body.classList.add('is-loading');
11868
+ if (totalEl) totalEl.textContent = 'loading…';
10902
11869
  try {
10903
11870
  const platformRow = document.getElementById('style-stats-platform-pills');
10904
11871
  const projectRow = document.getElementById('style-stats-project-pills');
@@ -10913,7 +11880,9 @@ async function loadStyleStats() {
10913
11880
  getStyleMeta(),
10914
11881
  ]);
10915
11882
  renderStyleStats(statsRes, meta);
10916
- } catch {}
11883
+ } catch {} finally {
11884
+ if (body) body.classList.remove('is-loading');
11885
+ }
10917
11886
  }
10918
11887
 
10919
11888
  // Score-cohort distribution. Buckets posts in the trailing window into
@@ -11016,6 +11985,10 @@ function renderCohortStats(payload) {
11016
11985
  }
11017
11986
 
11018
11987
  async function loadCohortStats() {
11988
+ const body = document.getElementById('cohort-stats-body');
11989
+ const totalEl = document.getElementById('cohort-stats-total');
11990
+ if (body) body.classList.add('is-loading');
11991
+ if (totalEl) totalEl.textContent = 'loading…';
11019
11992
  try {
11020
11993
  const platformRow = document.getElementById('style-stats-platform-pills');
11021
11994
  const projectRow = document.getElementById('style-stats-project-pills');
@@ -11029,8 +12002,9 @@ async function loadCohortStats() {
11029
12002
  const data = await res.json();
11030
12003
  renderCohortStats(data);
11031
12004
  } catch (e) {
11032
- const body = document.getElementById('cohort-stats-body');
11033
12005
  if (body) body.innerHTML = '<div class="style-stats-empty">Failed to load cohort stats.</div>';
12006
+ } finally {
12007
+ if (body) body.classList.remove('is-loading');
11034
12008
  }
11035
12009
  }
11036
12010
 
@@ -11565,34 +12539,40 @@ function renderCostStats(payload) {
11565
12539
  const byType = {};
11566
12540
  rows.forEach(r => { byType[r.type] = r; });
11567
12541
  const merged = COST_TYPE_ORDER.map(t => {
11568
- const r = byType[t] || { count: 0, total_cost_usd: 0, total_cost_usd_orchestrator: 0, total_cost_usd_estimated: 0 };
12542
+ const r = byType[t] || { count: 0, total_cost_usd: 0, total_cost_usd_orchestrator: 0, total_cost_usd_estimated: 0, total_cost_usd_subagent: 0 };
11569
12543
  const count = Number(r.count) || 0;
11570
- const total = Number(r.total_cost_usd) || 0;
12544
+ // SDK-only: total = orchestrator. Estimate/subagent kept for diagnostic
12545
+ // tooltips. /api/cost/stats SQL emits total_cost_usd = SUM(per_row_cost)
12546
+ // which itself is SUM(orchestrator/rows), so it equals total_cost_usd_
12547
+ // orchestrator by construction in SDK-only mode.
11571
12548
  const totalOrch = r.total_cost_usd_orchestrator != null ? Number(r.total_cost_usd_orchestrator) : null;
11572
12549
  const totalEst = r.total_cost_usd_estimated != null ? Number(r.total_cost_usd_estimated) : null;
12550
+ const totalSub = r.total_cost_usd_subagent != null ? Number(r.total_cost_usd_subagent) : null;
12551
+ const total = totalOrch != null ? totalOrch : 0;
11573
12552
  return {
11574
12553
  type: t, label: COST_TYPE_LABELS[t], count: count,
11575
- total: total, totalOrch: totalOrch, totalEst: totalEst,
12554
+ total: total, totalOrch: totalOrch, totalEst: totalEst, totalSub: totalSub,
11576
12555
  avg: count > 0 ? total / count : 0,
11577
12556
  avgOrch: count > 0 && totalOrch != null ? totalOrch / count : null,
11578
12557
  avgEst: count > 0 && totalEst != null ? totalEst / count : null,
12558
+ avgSub: count > 0 && totalSub != null ? totalSub / count : null,
11579
12559
  };
11580
12560
  });
11581
12561
  const totalCount = merged.reduce(function (a, r) { return a + r.count; }, 0);
11582
12562
  const totalCost = merged.reduce(function (a, r) { return a + r.total; }, 0);
11583
12563
  const totalOrch = merged.reduce(function (a, r) { return a + (r.totalOrch || 0); }, 0);
11584
12564
  const totalEst = merged.reduce(function (a, r) { return a + (r.totalEst || 0); }, 0);
12565
+ const totalSub = merged.reduce(function (a, r) { return a + (r.totalSub || 0); }, 0);
11585
12566
  if (totalEl) {
11586
12567
  totalEl.textContent = '$' + totalCost.toFixed(2) + ' · ' + totalCount.toLocaleString() + ' activit' + (totalCount === 1 ? 'y' : 'ies');
11587
- // Tooltip on the header pill so users can see both lanes for the
11588
- // headline figure without expanding the table.
11589
12568
  const tipLines = [
11590
- 'Orchestrator (SDK): $' + totalOrch.toFixed(4),
11591
- 'Estimated (transcript): $' + totalEst.toFixed(4),
12569
+ 'Cost (SDK orchestrator): $' + totalOrch.toFixed(4),
11592
12570
  '',
11593
- 'Displayed total prefers the SDK orchestrator cost (native streamRes.total_cost_usd) and falls back to the manual transcript estimate where the SDK value is missing.',
12571
+ 'Diagnostic-only (local pricing estimate, not actual billing):',
12572
+ ' Transcript estimate: $' + totalEst.toFixed(4),
12573
+ ' Subagent (est): $' + totalSub.toFixed(4),
11594
12574
  '',
11595
- 'Note: orchestrator cost EXCLUDES Task subagent spend (anthropics/claude-code #43945).',
12575
+ 'Anthropic SDK-reported orchestrator_cost_usd. Pipelines whose wrappers don\\'t capture --orchestrator-cost-usd contribute $0 see the per-phase table for which scripts are missing coverage.',
11596
12576
  ];
11597
12577
  totalEl.setAttribute('data-tooltip', tipLines.join('\\n'));
11598
12578
  totalEl.style.cursor = 'help';
@@ -11605,56 +12585,110 @@ function renderCostStats(payload) {
11605
12585
  return '$' + n.toFixed(2);
11606
12586
  }
11607
12587
  function fmtCount(v) { return (Number(v) || 0).toLocaleString(); }
11608
- // Wraps a money cell in a span that exposes both cost lanes via tooltip.
11609
- function moneyCell(displayed, orch, est) {
12588
+ function moneyCell(displayed, orch, est, sub) {
11610
12589
  const tip = [
11611
- 'Orchestrator (SDK): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
11612
- 'Estimated (transcript): ' + (est != null ? fmtMoney(est) : 'n/a'),
12590
+ 'Cost (SDK orchestrator): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
11613
12591
  '',
11614
- 'Displayed value prefers SDK; falls back to transcript estimate. Subagent costs excluded (anthropics/claude-code #43945).',
12592
+ 'Diagnostic-only (local pricing estimate):',
12593
+ ' Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
12594
+ ' Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
11615
12595
  ].join('\\n');
11616
12596
  return '<span data-tooltip="' + escapeHtml(tip) +
11617
12597
  '" style="cursor:help;border-bottom:1px dotted var(--text-muted);">' +
11618
12598
  fmtMoney(displayed) + '</span>';
11619
12599
  }
11620
12600
  const rowsHtml = merged.map(function (r) {
11621
- const totalCellHtml = moneyCell(r.total, r.totalOrch, r.totalEst);
12601
+ const totalCellHtml = moneyCell(r.total, r.totalOrch, r.totalEst, r.totalSub);
12602
+ const subCellHtml = r.totalSub != null && r.totalSub > 0 ? fmtMoney(r.totalSub) : '<span style="color:var(--text-very-faint);">$0</span>';
11622
12603
  const avgCellHtml = r.count > 0
11623
- ? moneyCell(r.avg, r.avgOrch, r.avgEst)
12604
+ ? moneyCell(r.avg, r.avgOrch, r.avgEst, r.avgSub)
11624
12605
  : '&mdash;';
11625
12606
  return '<tr>' +
11626
12607
  '<td>' + escapeHtml(r.label) + '</td>' +
11627
12608
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(r.count) + '</td>' +
11628
12609
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + totalCellHtml + '</td>' +
12610
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + subCellHtml + '</td>' +
11629
12611
  '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + avgCellHtml + '</td>' +
11630
12612
  '</tr>';
11631
12613
  }).join('');
11632
- const footerTotalHtml = moneyCell(totalCost, totalOrch, totalEst);
12614
+ const footerTotalHtml = moneyCell(totalCost, totalOrch, totalEst, totalSub);
12615
+ const footerSubHtml = totalSub > 0 ? fmtMoney(totalSub) : '<span style="color:var(--text-very-faint);">$0</span>';
11633
12616
  const footerAvgHtml = totalCount > 0
11634
12617
  ? moneyCell(totalCost / totalCount,
11635
12618
  totalOrch / totalCount,
11636
- totalEst / totalCount)
12619
+ totalEst / totalCount,
12620
+ totalSub / totalCount)
11637
12621
  : '&mdash;';
11638
12622
  const footerHtml =
11639
12623
  '<tr style="border-top:2px solid var(--border);font-weight:600;background:var(--bg-subtle);">' +
11640
12624
  '<td>Total</td>' +
11641
12625
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(totalCount) + '</td>' +
11642
12626
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerTotalHtml + '</td>' +
12627
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerSubHtml + '</td>' +
11643
12628
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerAvgHtml + '</td>' +
11644
12629
  '</tr>';
12630
+ // Per-phase (script) breakdown. Same window, separate query — gives the
12631
+ // operator an answer to "which phase of which pipeline is burning cash?"
12632
+ // independent of the activity-type rollup above. A row like
12633
+ // run-twitter-cycle-scan dominating the spend is the signal to investigate.
12634
+ const phases = (payload && payload.phases) || [];
12635
+ let phaseTableHtml = '';
12636
+ if (phases.length) {
12637
+ const phaseRowsHtml = phases.map(function (p) {
12638
+ const orch = p.total_cost_usd_orchestrator != null ? Number(p.total_cost_usd_orchestrator) : 0;
12639
+ const est = p.total_cost_usd_estimated != null ? Number(p.total_cost_usd_estimated) : null;
12640
+ const sub = p.total_cost_usd_subagent != null ? Number(p.total_cost_usd_subagent) : null;
12641
+ const sessions = Number(p.sessions) || 0;
12642
+ const withSdk = Number(p.sessions_with_sdk) || 0;
12643
+ const missing = Math.max(0, sessions - withSdk);
12644
+ // SDK-only: per-phase total = orchestrator sum. Phases with 0% SDK
12645
+ // coverage show $0 with a "(N/N missing)" hint so it's obvious the
12646
+ // wrapper needs patching.
12647
+ const totalCellInner = withSdk > 0
12648
+ ? moneyCell(orch, orch, est, sub)
12649
+ : '<span style="color:var(--text-very-faint);">n/a</span>';
12650
+ const coverageCell = missing === 0
12651
+ ? ('<span style="color:#15803d;">' + sessions + '/' + sessions + '</span>')
12652
+ : ('<span style="color:#b91c1c;" title="' + missing + ' sessions missing SDK cost (wrapper script needs --output-format json on claude call)">' +
12653
+ withSdk + '/' + sessions + '</span>');
12654
+ const perSession = withSdk > 0
12655
+ ? moneyCell(orch / withSdk, orch / withSdk, est != null ? est / sessions : null, sub != null ? sub / sessions : null)
12656
+ : '&mdash;';
12657
+ return '<tr>' +
12658
+ '<td style="font-family:ui-monospace,monospace;font-size:12px;">' + escapeHtml(p.phase || '(unknown)') + '</td>' +
12659
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(sessions) + '</td>' +
12660
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;font-size:11px;">' + coverageCell + '</td>' +
12661
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + totalCellInner + '</td>' +
12662
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + perSession + '</td>' +
12663
+ '</tr>';
12664
+ }).join('');
12665
+ phaseTableHtml =
12666
+ '<div style="font-size:12px;font-weight:600;padding:12px 2px 4px;color:var(--text-secondary);">Cost per Phase (Claude session script)</div>' +
12667
+ '<table class="style-stats-table">' +
12668
+ '<thead><tr>' +
12669
+ '<th style="text-align:left;">Phase</th>' +
12670
+ '<th style="text-align:right;">Sessions</th>' +
12671
+ '<th style="text-align:right;" title="Sessions with SDK cost captured / total sessions. Red = wrapper needs patching.">SDK coverage</th>' +
12672
+ '<th style="text-align:right;">Cost (SDK)</th>' +
12673
+ '<th style="text-align:right;">Cost per Session</th>' +
12674
+ '</tr></thead>' +
12675
+ '<tbody>' + phaseRowsHtml + '</tbody>' +
12676
+ '</table>';
12677
+ }
11645
12678
  body.innerHTML =
11646
12679
  '<table class="style-stats-table">' +
11647
12680
  '<thead><tr>' +
11648
12681
  '<th style="text-align:left;">Type</th>' +
11649
12682
  '<th style="text-align:right;">Activities</th>' +
11650
12683
  '<th style="text-align:right;">Total Cost</th>' +
12684
+ '<th style="text-align:right;">Subagent</th>' +
11651
12685
  '<th style="text-align:right;">Cost per Activity</th>' +
11652
12686
  '</tr></thead>' +
11653
12687
  '<tbody>' + rowsHtml + footerHtml + '</tbody>' +
11654
12688
  '</table>' +
12689
+ phaseTableHtml +
11655
12690
  '<div style="font-size:11px;color:var(--text-muted);padding:8px 2px 2px;">' +
11656
- 'Cost is Claude session spend split evenly across the activity rows each session produced. ' +
11657
- 'Totals here exclude skipped replies, resurrected posts, DM replies, and mentions.' +
12691
+ 'SDK-only mode (2026-05-15): cost = Anthropic\\'s orchestrator_cost_usd from the SDK result event. Pipelines whose wrappers don\\'t pass --output-format json to claude show $0 with a red SDK-coverage cell — that\\'s a missing-data signal, not a real $0. Transcript estimate and Task-subagent figures (tooltip-only) come from a local pricing table and don\\'t reflect subscription billing.' +
11658
12692
  '</div>';
11659
12693
  }
11660
12694
 
@@ -11707,6 +12741,10 @@ let _topDmsTableState = { sortField: 'rank', sortDir: 'asc', filters: {} };
11707
12741
  let _topDmsLoaded = false;
11708
12742
  let _topDmsLoading = false;
11709
12743
  let _topDmsPayload = null;
12744
+ let _topLinksTableState = { sortField: 'real_clicks', sortDir: 'desc', filters: {} };
12745
+ let _topLinksLoaded = false;
12746
+ let _topLinksLoading = false;
12747
+ let _topLinksPayload = null;
11710
12748
  let _topDmDir = saLoad('sa.top.dmDir.v1', 'all');
11711
12749
  let _topDmInterest = saLoad('sa.top.dmInterest.v1', 'all');
11712
12750
  let _topDmMode = saLoad('sa.top.dmMode.v1', 'all');
@@ -12239,6 +13277,7 @@ const TOP_SUBTAB_HELP = {
12239
13277
  comments: 'Top comments your accounts have left under other people’s threads, ranked by reach and reactions.',
12240
13278
  pages: 'Top landing/SEO pages on your sites this period, ranked by pageviews.',
12241
13279
  dms: 'Direct message conversations with prospects, ranked by recent activity.',
13280
+ links: 'Destination URLs across all posts, ranked by clicks. One row per unique target URL (homepage vs audience pages vs SEO pages vs booking).',
12242
13281
  };
12243
13282
  function syncTopSubtabHelp() {
12244
13283
  const el = document.getElementById('top-subtab-help');
@@ -12291,12 +13330,14 @@ function initTopFilters() {
12291
13330
  saveDashboardWindow(_topWindow);
12292
13331
  if (_topSubtab === 'pages') loadTopPages(true);
12293
13332
  else if (_topSubtab === 'dms') { _topDmOffset = 0; loadTopDms(true); }
13333
+ else if (_topSubtab === 'links') loadTopLinks(true);
12294
13334
  else loadTopPosts(true);
12295
13335
  });
12296
13336
  wireTopPillRow('top-platform-pills', (v) => {
12297
13337
  _topPlatform = v || 'all';
12298
13338
  saSave('sa.top.platform.v1', _topPlatform);
12299
13339
  if (_topSubtab === 'dms') { _topDmOffset = 0; loadTopDms(true); }
13340
+ else if (_topSubtab === 'links') loadTopLinks(true);
12300
13341
  else loadTopPosts(true);
12301
13342
  });
12302
13343
  wireTopPillRow('top-project-pills', (v) => {
@@ -12304,6 +13345,7 @@ function initTopFilters() {
12304
13345
  saSave('sa.top.project.v1', _topProject);
12305
13346
  if (_topSubtab === 'pages') renderTopPagesFromCache();
12306
13347
  else if (_topSubtab === 'dms') { if (_topDmsPayload) renderTopDms(_topDmsPayload); }
13348
+ else if (_topSubtab === 'links') loadTopLinks(true);
12307
13349
  else loadTopPosts(true); // refetch so the SQL LIMIT applies AFTER project filter
12308
13350
  });
12309
13351
  wireTopPillRow('top-campaign-pills', (v) => {
@@ -12422,6 +13464,7 @@ function applyTopSubtabState(sub, loadData) {
12422
13464
  const pagesC = document.getElementById('top-pages-container');
12423
13465
  const pagesUnknownC = document.getElementById('top-pages-unknown-container');
12424
13466
  const dmsC = document.getElementById('top-dms-container');
13467
+ const linksC = document.getElementById('top-links-container');
12425
13468
  const platRowEl = document.getElementById('top-platform-pills');
12426
13469
  const projRowEl = document.getElementById('top-project-pills');
12427
13470
  const campRowEl = document.getElementById('top-campaign-pills');
@@ -12448,6 +13491,7 @@ function applyTopSubtabState(sub, loadData) {
12448
13491
  if (sub === 'pages') {
12449
13492
  if (postsC) postsC.classList.add('hidden');
12450
13493
  if (dmsC) dmsC.classList.add('hidden');
13494
+ if (linksC) linksC.classList.add('hidden');
12451
13495
  if (pagesC) pagesC.classList.remove('hidden');
12452
13496
  if (pagesUnknownC) pagesUnknownC.classList.remove('hidden');
12453
13497
  if (platRowEl) platRowEl.classList.add('hidden');
@@ -12461,6 +13505,7 @@ function applyTopSubtabState(sub, loadData) {
12461
13505
  if (postsC) postsC.classList.add('hidden');
12462
13506
  if (pagesC) pagesC.classList.add('hidden');
12463
13507
  if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
13508
+ if (linksC) linksC.classList.add('hidden');
12464
13509
  if (dmsC) dmsC.classList.remove('hidden');
12465
13510
  if (platRowEl) platRowEl.classList.remove('hidden');
12466
13511
  if (srcRowEl) srcRowEl.classList.add('hidden');
@@ -12474,10 +13519,29 @@ function applyTopSubtabState(sub, loadData) {
12474
13519
  searchElDm.value = _topDmSearch || '';
12475
13520
  }
12476
13521
  if (loadData) loadTopDms(true);
13522
+ } else if (sub === 'links') {
13523
+ if (postsC) postsC.classList.add('hidden');
13524
+ if (pagesC) pagesC.classList.add('hidden');
13525
+ if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
13526
+ if (dmsC) dmsC.classList.add('hidden');
13527
+ if (linksC) linksC.classList.remove('hidden');
13528
+ if (platRowEl) platRowEl.classList.remove('hidden');
13529
+ if (srcRowEl) srcRowEl.classList.add('hidden');
13530
+ if (campRowEl) campRowEl.classList.add('hidden');
13531
+ setDmRowsHidden(true);
13532
+ setLinkPillHidden(true);
13533
+ if (totalEl) totalEl.textContent = '';
13534
+ const searchElLinks = document.getElementById('top-search');
13535
+ if (searchElLinks) {
13536
+ searchElLinks.placeholder = 'Filter destinations by URL\u2026';
13537
+ searchElLinks.value = (_topLinksTableState && _topLinksTableState.globalQuery) || '';
13538
+ }
13539
+ if (loadData) loadTopLinks(true);
12477
13540
  } else {
12478
13541
  if (pagesC) pagesC.classList.add('hidden');
12479
13542
  if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
12480
13543
  if (dmsC) dmsC.classList.add('hidden');
13544
+ if (linksC) linksC.classList.add('hidden');
12481
13545
  if (postsC) postsC.classList.remove('hidden');
12482
13546
  if (platRowEl) platRowEl.classList.remove('hidden');
12483
13547
  if (srcRowEl) srcRowEl.classList.add('hidden');
@@ -12657,6 +13721,151 @@ async function loadTopPages(force) {
12657
13721
  }
12658
13722
  }
12659
13723
 
13724
+ // Render the colored kind badge for a destination row. The server's
13725
+ // /api/top/destinations endpoint classifies each row into one of seven kind
13726
+ // buckets (home / subpage / audience_page / seo / booking / github /
13727
+ // external / other) by reading config.json, so the client just looks up
13728
+ // the label and color here.
13729
+ function destinationKindBadge(kind, audienceAngle) {
13730
+ const map = {
13731
+ home: { cls: 'dest-kind-home', label: 'HOME' },
13732
+ subpage: { cls: 'dest-kind-subpage', label: 'SUBPAGE' },
13733
+ audience_page: { cls: 'dest-kind-subpage', label: 'AUDIENCE' },
13734
+ seo: { cls: 'dest-kind-seo', label: 'SEO' },
13735
+ booking: { cls: 'dest-kind-booking', label: 'BOOKING' },
13736
+ github: { cls: 'dest-kind-github', label: 'GITHUB' },
13737
+ external: { cls: 'dest-kind-external', label: 'EXT' },
13738
+ other: { cls: 'dest-kind-other', label: 'OTHER' },
13739
+ };
13740
+ const m = map[kind] || map.other;
13741
+ let label = m.label;
13742
+ if (kind === 'audience_page' && audienceAngle) {
13743
+ label = 'AUDIENCE: ' + audienceAngle;
13744
+ }
13745
+ return '<span class="dest-kind-badge ' + m.cls + '" title="' + escapeHtml(kind) + '">' + escapeHtml(label) + '</span>';
13746
+ }
13747
+
13748
+ async function loadTopLinks(force) {
13749
+ if (_topLinksLoading) return;
13750
+ const container = document.getElementById('top-links-container');
13751
+ if (!_topLinksPayload && container) {
13752
+ container.innerHTML = '<div class="style-stats-empty">Loading\u2026</div>';
13753
+ }
13754
+ _topLinksLoading = true;
13755
+ try {
13756
+ const params = new URLSearchParams();
13757
+ if (_topWindow) params.set('window', _topWindow);
13758
+ if (_topPlatform && _topPlatform !== 'all') params.set('platform', _topPlatform);
13759
+ if (_topProject && _topProject !== 'all') params.set('project', _topProject);
13760
+ const res = await fetch('/api/top/destinations?' + params.toString());
13761
+ const data = await res.json();
13762
+ _topLinksPayload = data;
13763
+ renderTopLinks(data);
13764
+ _topLinksLoaded = true;
13765
+ } catch (e) {
13766
+ if (container) container.innerHTML = '<div class="style-stats-empty">Failed to load.</div>';
13767
+ } finally {
13768
+ _topLinksLoading = false;
13769
+ }
13770
+ }
13771
+
13772
+ function renderTopLinks(payload) {
13773
+ const container = document.getElementById('top-links-container');
13774
+ if (!container) return;
13775
+ const totalEl = document.getElementById('top-total');
13776
+ const fmt = n => (Number(n) || 0).toLocaleString();
13777
+ const dests = Array.isArray(payload && payload.destinations) ? payload.destinations : [];
13778
+ if (!dests.length) {
13779
+ container.innerHTML = '<div class="style-stats-empty">No destinations in this window yet. Posts with linked URLs will show up here once they accrue clicks.</div>';
13780
+ if (totalEl) totalEl.textContent = '';
13781
+ return;
13782
+ }
13783
+ // Roll up real_clicks vs the legacy/backfill columns: prefer plc.real_clicks
13784
+ // (post-2026-05-07 per-hit log), fall back to pl.real_clicks (PostHog
13785
+ // backfill for older rows), final fallback pl.clicks (legacy counter).
13786
+ const rows = dests.map(d => {
13787
+ const kind = d.kind || 'other';
13788
+ const realClicks = Number(d.real_clicks || 0);
13789
+ const backfillReal = Number(d.backfill_real || 0);
13790
+ const legacyClicks = Number(d.legacy_clicks || 0);
13791
+ const botClicks = Number(d.bot_clicks || 0);
13792
+ const effectiveClicks = realClicks > 0 ? realClicks : (backfillReal > 0 ? backfillReal : legacyClicks);
13793
+ return {
13794
+ target_url: d.target_url || '',
13795
+ project_name: d.project_name || '',
13796
+ platform: d.platform || '',
13797
+ kind,
13798
+ audience_page_angle: d.audience_page_angle || null,
13799
+ kind_label: kind,
13800
+ posts: Number(d.posts || 0),
13801
+ codes: Number(d.codes || 0),
13802
+ real_clicks: realClicks,
13803
+ backfill_real: backfillReal,
13804
+ legacy_clicks: legacyClicks,
13805
+ bot_clicks: botClicks,
13806
+ effective_clicks: effectiveClicks,
13807
+ first_minted_at: d.first_minted_at || null,
13808
+ last_click_at: d.last_click_at || null,
13809
+ };
13810
+ });
13811
+ if (totalEl) {
13812
+ const totalClicks = rows.reduce((a, r) => a + r.effective_clicks, 0);
13813
+ totalEl.textContent = rows.length + ' destination' + (rows.length === 1 ? '' : 's') + ' \u00b7 ' + fmt(totalClicks) + ' clicks';
13814
+ }
13815
+ const fmtUrl = (_v, r) => {
13816
+ const safe = escapeHtml(r.target_url);
13817
+ return '<a href="' + safe + '" target="_blank" rel="noopener" class="top-post-link">'
13818
+ + destinationKindBadge(r.kind, r.audience_page_angle)
13819
+ + ' <span style="word-break:break-all">' + safe + '</span>'
13820
+ + '</a>';
13821
+ };
13822
+ const fmtAgo = (v) => {
13823
+ if (!v) return '\u2014';
13824
+ try {
13825
+ const d = new Date(v);
13826
+ const diff = Date.now() - d.getTime();
13827
+ const days = Math.floor(diff / 86400000);
13828
+ if (days < 1) {
13829
+ const hours = Math.floor(diff / 3600000);
13830
+ if (hours < 1) {
13831
+ const mins = Math.max(0, Math.floor(diff / 60000));
13832
+ return mins + 'm ago';
13833
+ }
13834
+ return hours + 'h ago';
13835
+ }
13836
+ return days + 'd ago';
13837
+ } catch (_e) { return '\u2014'; }
13838
+ };
13839
+ const fmtClicks = (v, r) => {
13840
+ const main = fmt(r.effective_clicks);
13841
+ const bits = [];
13842
+ if (r.real_clicks > 0) bits.push(r.real_clicks + ' real');
13843
+ else if (r.backfill_real > 0) bits.push(r.backfill_real + ' backfill');
13844
+ else if (r.legacy_clicks > 0) bits.push(r.legacy_clicks + ' legacy');
13845
+ if (r.bot_clicks > 0) bits.push(r.bot_clicks + ' bot');
13846
+ const sub = bits.length ? '<div style="font-size:11px;color:var(--text-secondary)">' + escapeHtml(bits.join(' \u00b7 ')) + '</div>' : '';
13847
+ return '<div style="font-weight:600">' + main + '</div>' + sub;
13848
+ };
13849
+ const columns = [
13850
+ { key: 'target_url', label: 'Destination', type: 'text', align: 'left', widthPct: 48, formatter: fmtUrl },
13851
+ { key: 'project_name', label: 'Project', type: 'text', align: 'left', widthPct: 10, formatter: v => escapeHtml(v) },
13852
+ { key: 'platform', label: 'Platform', type: 'text', align: 'left', widthPct: 8, formatter: v => escapeHtml(v) },
13853
+ { key: 'posts', label: 'Posts', type: 'numeric', align: 'right', widthPct: 7, formatter: fmt },
13854
+ { key: 'codes', label: 'Codes', type: 'numeric', align: 'right', widthPct: 7, formatter: fmt },
13855
+ { key: 'effective_clicks', label: 'Clicks', type: 'numeric', align: 'right', widthPct: 12, formatter: fmtClicks },
13856
+ { key: 'last_click_at', label: 'Last click', type: 'date', align: 'right', widthPct: 8, formatter: fmtAgo },
13857
+ ];
13858
+ container.innerHTML = '';
13859
+ mountSortableTable({
13860
+ containerId: 'top-links-container',
13861
+ rows,
13862
+ state: _topLinksTableState,
13863
+ storageKey: 'sa.topLinksTable.v1',
13864
+ columns,
13865
+ emptyMessage: 'No destinations in this window yet.',
13866
+ });
13867
+ }
13868
+
12660
13869
  function dmClassBadge(dm) {
12661
13870
  const status = String(dm.conversation_status || '').toLowerCase();
12662
13871
  const interest = String(dm.interest_level || '').toLowerCase();
@@ -13888,6 +15097,7 @@ function renderProjectStatus(data, opts) {
13888
15097
  const grandCost = Number(data && data.grand_cost_usd) || 0;
13889
15098
  const grandCostOrch = Number(data && data.grand_cost_usd_orchestrator) || 0;
13890
15099
  const grandCostEst = Number(data && data.grand_cost_usd_estimated) || 0;
15100
+ const grandCostSub = Number(data && data.grand_cost_usd_subagent) || 0;
13891
15101
  // Money formatter mirrors fmtCost: $0, $0.0042, $12.34.
13892
15102
  const fmtMoney = (v) => {
13893
15103
  const n = Number(v) || 0;
@@ -13897,12 +15107,17 @@ function renderProjectStatus(data, opts) {
13897
15107
  };
13898
15108
  // Money cell with tooltip exposing SDK + estimate lanes, same UX as
13899
15109
  // moneyCell in renderCostStats so operators see consistent numbers.
13900
- const costCell = (displayed, orch, est, opts) => {
15110
+ const costCell = (displayed, orch, est, sub, opts) => {
15111
+ // SDK-only mode: displayed value comes from orchestrator_cost_usd; the
15112
+ // estimate and subagent are diagnostic-only (local pricing table).
13901
15113
  const tip = [
13902
- 'Orchestrator (SDK): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
13903
- 'Estimated (transcript): ' + (est != null ? fmtMoney(est) : 'n/a'),
15114
+ 'Cost (SDK orchestrator): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
15115
+ '',
15116
+ 'Diagnostic-only (local pricing estimate, not actual billing):',
15117
+ ' Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
15118
+ ' Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
13904
15119
  '',
13905
- 'Displayed value prefers SDK; falls back to transcript estimate. Subagent costs excluded (anthropics/claude-code #43945).',
15120
+ 'Anthropic SDK-reported cost only. "n/a" or $0 means the wrapper didn\\'t capture --orchestrator-cost-usd (no --output-format json on the claude call) for one or more sessions in this window. Subagent and transcript estimates are computed from a local pricing table and are not billing-accurate on subscription plans.',
13906
15121
  ].join('\\n');
13907
15122
  const style = 'text-align:right;font-variant-numeric:tabular-nums;' + (opts && opts.extra || '');
13908
15123
  const inner = '<span data-tooltip="' + escapeHtml(tip) +
@@ -13918,10 +15133,13 @@ function renderProjectStatus(data, opts) {
13918
15133
  : base;
13919
15134
  if (costAvailable) {
13920
15135
  const tipLines = [
13921
- 'Orchestrator (SDK): ' + fmtMoney(grandCostOrch),
13922
- 'Estimated (transcript): ' + fmtMoney(grandCostEst),
15136
+ 'Cost (SDK orchestrator): ' + fmtMoney(grandCostOrch),
13923
15137
  '',
13924
- 'Total Claude session cost across all activity rows (posts, comments, DMs, SEO pages) attributed to projects in this window. Same attribution model as Cost per Activity.',
15138
+ 'Diagnostic-only (local pricing estimate, not actual billing):',
15139
+ ' Transcript estimate: ' + fmtMoney(grandCostEst),
15140
+ ' Subagent (est): ' + fmtMoney(grandCostSub),
15141
+ '',
15142
+ 'Anthropic SDK-reported orchestrator_cost_usd across all activity rows in this window. Per-session attribution is the session\\'s cost split evenly across its activity rows. Pipelines whose wrappers don\\'t pass --output-format json to claude contribute $0.',
13925
15143
  ];
13926
15144
  totalEl.setAttribute('data-tooltip', tipLines.join('\\n'));
13927
15145
  totalEl.style.cursor = 'help';
@@ -14000,7 +15218,7 @@ function renderProjectStatus(data, opts) {
14000
15218
  : nameCell;
14001
15219
  const totalCell = cellWithShare(r.total, grandTotal, targetShare, { extra: 'font-weight:600;', showZeroShare: true });
14002
15220
  const costCellHtml = costAvailable
14003
- ? costCell(Number(r.cost_usd) || 0, Number(r.cost_usd_orchestrator) || 0, Number(r.cost_usd_estimated) || 0, { extra: 'color:var(--text-secondary);' })
15221
+ ? costCell(Number(r.cost_usd) || 0, Number(r.cost_usd_orchestrator) || 0, Number(r.cost_usd_estimated) || 0, Number(r.cost_usd_subagent) || 0, { extra: 'color:var(--text-secondary);' })
14004
15222
  : '';
14005
15223
  const weightVal = Number(r.weight) || 0;
14006
15224
  const editable = canEditWeight && (!r.unassigned || r.configured);
@@ -14036,7 +15254,7 @@ function renderProjectStatus(data, opts) {
14036
15254
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + (Number(totals[p]) || 0) + '</td>'
14037
15255
  ).join('');
14038
15256
  const footerCostCell = costAvailable
14039
- ? costCell(grandCost, grandCostOrch, grandCostEst, { extra: 'font-weight:600;' })
15257
+ ? costCell(grandCost, grandCostOrch, grandCostEst, grandCostSub, { extra: 'font-weight:600;' })
14040
15258
  : '';
14041
15259
  const footerHtml =
14042
15260
  '<tr style="border-top:2px solid var(--border);font-weight:600;background:var(--bg-subtle);">' +
@@ -14379,7 +15597,7 @@ function renderActivity(events) {
14379
15597
  '</div>' +
14380
15598
  '</td>' +
14381
15599
  '<td class="activity-summary">' + summaryHtml + '</td>' +
14382
- '<td class="sa-admin-only" style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-secondary);">' + fmtCostCell(e.cost_usd, e.cost_usd_orchestrator, e.cost_usd_estimated) + '</td>' +
15600
+ '<td class="sa-admin-only" style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-secondary);">' + fmtCostCell(e.cost_usd, e.cost_usd_orchestrator, e.cost_usd_estimated, e.cost_usd_subagent) + '</td>' +
14383
15601
  '<td style="text-align:center;">' + renderDeleteBtnHtml(e) + '</td>' +
14384
15602
  '</tr>';
14385
15603
  }).join('');
@@ -14941,6 +16159,25 @@ function renderHtml() {
14941
16159
  .replace('__SA_POSTHOG_CONFIG_PLACEHOLDER__', JSON.stringify(posthogWebConfig()));
14942
16160
  }
14943
16161
 
16162
+ function renderTikTokOauthCallback(rawUrl) {
16163
+ const u = new URL(rawUrl, 'http://localhost');
16164
+ const code = u.searchParams.get('code') || '';
16165
+ const state = u.searchParams.get('state') || '';
16166
+ const scopes = u.searchParams.get('scopes') || '';
16167
+ const err = u.searchParams.get('error') || '';
16168
+ const errDesc = u.searchParams.get('error_description') || '';
16169
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
16170
+ const ok = !!code && !err;
16171
+ const banner = ok
16172
+ ? '<div style="background:#ecfdf5;color:#065f46;padding:12px 16px;border-radius:8px;margin-bottom:24px">Authorization successful. Copy the code below and paste it into the local oauth_helper.py script.</div>'
16173
+ : `<div style="background:#fef2f2;color:#991b1b;padding:12px 16px;border-radius:8px;margin-bottom:24px">Authorization error: ${esc(err || 'no code in callback')}${errDesc ? ` &mdash; ${esc(errDesc)}` : ''}</div>`;
16174
+ const codeBox = ok ? `<div><label style="display:block;font-size:13px;color:#52525b;margin-bottom:6px">Authorization code</label><textarea id="tt-code" readonly style="width:100%;font-family:ui-monospace,SFMono-Regular,monospace;font-size:14px;padding:10px;border:1px solid #d4d4d8;border-radius:8px;background:#fafafa" rows="3" onclick="this.select()">${esc(code)}</textarea><button id="tt-copy" style="margin-top:8px;padding:8px 14px;background:#18181b;color:#fff;border:0;border-radius:6px;cursor:pointer;font-size:13px">Copy</button></div><script>(function(){var b=document.getElementById('tt-copy'),t=document.getElementById('tt-code');if(b&&t){b.addEventListener('click',function(){navigator.clipboard.writeText(t.value).then(function(){b.textContent='Copied';setTimeout(function(){b.textContent='Copy'},1500)})})}})();</script>` : '';
16175
+ const next = ok ? `<details style="margin-top:24px"><summary style="cursor:pointer;color:#27272a;font-weight:500">Next steps</summary><pre style="background:#0a0a0a;color:#fafafa;padding:16px;border-radius:8px;overflow:auto;font-size:13px;line-height:1.6;margin-top:12px">cd ~/social-autoposter
16176
+ python3 scripts/tiktok/oauth_helper.py exchange '${esc(code)}'</pre><p style="color:#52525b;font-size:13px;margin-top:12px">This exchanges the code for an access token + refresh token via the TikTok API using the client secret stored in your local macOS keychain, then writes the tokens to <code>~/tiktok-content-api/.env</code>.</p></details>` : '';
16177
+ const meta = ok ? `<dl style="display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin-top:24px;font-size:13px;color:#52525b"><dt>state</dt><dd style="font-family:ui-monospace,SFMono-Regular,monospace">${esc(state) || '<i>(none)</i>'}</dd><dt>scopes</dt><dd style="font-family:ui-monospace,SFMono-Regular,monospace">${esc(scopes) || '<i>(none)</i>'}</dd></dl>` : '';
16178
+ return `<!doctype html><html><head><meta charset="utf-8"><title>TikTok OAuth callback &middot; Meditation Fellow Studio</title><meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:-apple-system,BlinkMacSystemFont,Inter,system-ui,sans-serif;max-width:680px;margin:48px auto;padding:0 24px;color:#18181b;background:#fff;line-height:1.5}h1{font-size:24px;font-weight:600;margin:0 0 24px;letter-spacing:-0.01em}code{font-family:ui-monospace,SFMono-Regular,monospace;background:#f4f4f5;padding:1px 6px;border-radius:4px;font-size:0.9em}</style></head><body><h1>TikTok OAuth callback</h1>${banner}${codeBox}${meta}${next}</body></html>`;
16179
+ }
16180
+
14944
16181
  // --- Server ---
14945
16182
 
14946
16183
  const server = http.createServer((req, res) => {
@@ -14966,6 +16203,14 @@ const server = http.createServer((req, res) => {
14966
16203
  Promise.resolve(handleApi(req, res)).catch(e => {
14967
16204
  try { json(res, { error: e.message || String(e) }, 500); } catch {}
14968
16205
  });
16206
+ } else if (pathname === '/oauth/tiktok/callback') {
16207
+ // Minimal TikTok OAuth landing page. We do not exchange the code here
16208
+ // because the dashboard Cloud Run service does not carry the TikTok
16209
+ // client_secret. The operator pastes the displayed code into the local
16210
+ // scripts/tiktok/oauth_helper.py script which does the exchange via
16211
+ // keychain-stored credentials and writes ~/tiktok-content-api/.env.
16212
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
16213
+ res.end(renderTikTokOauthCallback(req.url));
14969
16214
  } else {
14970
16215
  res.writeHead(404);
14971
16216
  res.end('Not found');