social-autoposter 1.3.10 → 1.5.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 (86) hide show
  1. package/bin/server.js +2437 -296
  2. package/package.json +1 -1
  3. package/schema-postgres.sql +16 -0
  4. package/scripts/_insert_post_023.py +127 -0
  5. package/scripts/_scan_aggregate.py +108 -0
  6. package/scripts/_scan_timeline.py +85 -0
  7. package/scripts/_test_since_hook.py +117 -0
  8. package/scripts/audience_pages.py +243 -0
  9. package/scripts/classify_run_error.py +145 -0
  10. package/scripts/dm_db_update.py +69 -0
  11. package/scripts/dm_outreach_twitter_helper.py +129 -0
  12. package/scripts/dm_send_log.py +7 -0
  13. package/scripts/dm_short_links.py +54 -7
  14. package/scripts/engage_github.py +6 -5
  15. package/scripts/engage_reddit.py +44 -6
  16. package/scripts/engage_twitter_helper.py +197 -0
  17. package/scripts/engagement_styles.py +471 -24
  18. package/scripts/fetch_twitter_t1.py +41 -29
  19. package/scripts/github_tools.py +139 -10
  20. package/scripts/ig_post_type_picker.py +213 -38
  21. package/scripts/insert_post029.py +89 -0
  22. package/scripts/insert_post_024.py +110 -0
  23. package/scripts/insert_post_026.py +103 -0
  24. package/scripts/insert_post_039.py +80 -0
  25. package/scripts/insert_post_051.py +85 -0
  26. package/scripts/install_lane_digest.py +5 -1
  27. package/scripts/install_lane_monitor.py +1 -1
  28. package/scripts/log_post.py +43 -3
  29. package/scripts/log_run.py +47 -6
  30. package/scripts/log_twitter_search_attempts.py +21 -12
  31. package/scripts/log_twitter_skips.py +30 -55
  32. package/scripts/octolens_twitter_batch.py +34 -33
  33. package/scripts/octolens_twitter_cdp.py +29 -29
  34. package/scripts/pick_ig_account.py +192 -0
  35. package/scripts/pick_project.py +108 -43
  36. package/scripts/pick_twitter_thread_target.py +37 -80
  37. package/scripts/post_github.py +185 -68
  38. package/scripts/post_reddit.py +219 -52
  39. package/scripts/precompute_dashboard_stats.py +23 -6
  40. package/scripts/project_stats_json.py +321 -11
  41. package/scripts/reconcile_twitter_search_topic.py +125 -0
  42. package/scripts/reddit_browser.py +73 -5
  43. package/scripts/reddit_tools.py +42 -14
  44. package/scripts/regenerate_ig_plists.py +319 -0
  45. package/scripts/reply_db.py +6 -0
  46. package/scripts/scan_dm_candidates.py +19 -1
  47. package/scripts/scan_twitter_mentions_browser.py +150 -63
  48. package/scripts/scan_twitter_thread_followups.py +78 -52
  49. package/scripts/score_twitter_candidates.py +110 -103
  50. package/scripts/scrape_reddit_views.py +66 -2
  51. package/scripts/snapshot_style_targets.py +85 -0
  52. package/scripts/strike_alert.py +479 -18
  53. package/scripts/top_dud_twitter_queries.py +20 -34
  54. package/scripts/top_performers.py +279 -16
  55. package/scripts/top_twitter_queries.py +30 -124
  56. package/scripts/twitter_account.py +76 -0
  57. package/scripts/twitter_batch_phase.py +26 -44
  58. package/scripts/twitter_browser.py +31 -20
  59. package/scripts/twitter_cycle_helper.py +284 -0
  60. package/scripts/twitter_gen_links.py +57 -4
  61. package/scripts/twitter_post_plan.py +79 -40
  62. package/scripts/twitter_supply_signal.py +8 -53
  63. package/scripts/twitter_threads_helper.py +152 -0
  64. package/scripts/update_stats.py +380 -104
  65. package/scripts/version.py +72 -0
  66. package/scripts/watchdog_hung_runs.py +6 -1
  67. package/skill/dm-outreach-linkedin.sh +1 -1
  68. package/skill/dm-outreach-reddit.sh +1 -1
  69. package/skill/dm-outreach-twitter.sh +31 -57
  70. package/skill/engage-dm-replies.sh +1 -1
  71. package/skill/engage-linkedin.sh +2 -2
  72. package/skill/engage-reddit.sh +15 -0
  73. package/skill/engage-twitter.sh +101 -220
  74. package/skill/lib/twitter-backend.sh +71 -85
  75. package/skill/link-edit-github.sh +1 -1
  76. package/skill/link-edit-reddit.sh +1 -1
  77. package/skill/prewarm-funnel.sh +104 -0
  78. package/skill/run-github.sh +13 -1
  79. package/skill/run-instagram-daily.sh +32 -12
  80. package/skill/run-instagram-render.sh +332 -51
  81. package/skill/run-linkedin.sh +14 -3
  82. package/skill/run-reddit-search.sh +13 -0
  83. package/skill/run-twitter-cycle.sh +340 -244
  84. package/skill/run-twitter-threads.sh +19 -27
  85. package/skill/stats.sh +38 -3
  86. package/skill/styles.sh +74 -3
package/bin/server.js CHANGED
@@ -385,11 +385,19 @@ function getPool() {
385
385
  if (_pool) return _pool;
386
386
  const dbUrl = getDbUrl();
387
387
  if (!dbUrl) return null;
388
+ // Pool sized for the per-project breakdown load pattern: 8 pg-backed
389
+ // endpoints (views/upvotes/comments/clicks/posts/bookings/cost + funnel
390
+ // metadata) firing for 2-3 concurrent projects = ~24 simultaneous queries,
391
+ // on top of normal page load. max:5 caused ~219k connection timeouts in
392
+ // skill/logs/launchd-dashboard-stderr.log on 2026-05-19 because every
393
+ // pg-backed request started failing at the 10s connectionTimeoutMillis cap
394
+ // (blank Get Started card, empty per-project rows). Neon free tier allows
395
+ // 100 concurrent connections per project, so 25 is well within budget.
388
396
  _pool = new Pool({
389
397
  connectionString: dbUrl,
390
- max: 5,
398
+ max: 25,
391
399
  idleTimeoutMillis: 30000,
392
- connectionTimeoutMillis: 10000,
400
+ connectionTimeoutMillis: 30000,
393
401
  });
394
402
  _pool.on('error', (err) => {
395
403
  console.error('[pg.Pool] idle client error:', err.message);
@@ -416,9 +424,9 @@ function getBookingsPool() {
416
424
  if (!dbUrl) return null;
417
425
  _bookingsPool = new Pool({
418
426
  connectionString: dbUrl,
419
- max: 3,
427
+ max: 10,
420
428
  idleTimeoutMillis: 30000,
421
- connectionTimeoutMillis: 10000,
429
+ connectionTimeoutMillis: 30000,
422
430
  });
423
431
  _bookingsPool.on('error', (err) => {
424
432
  console.error('[bookings pg.Pool] idle client error:', err.message);
@@ -477,7 +485,11 @@ const RUN_MONITOR_PATH = path.join(LOG_DIR, 'run_monitor.log');
477
485
  // queries+candidates+above_floor only. Each sub-key is omitted when zero, so
478
486
  // `discover=` itself is absent on lines from pipelines that don't emit it.
479
487
  // 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|]+))?/;
488
+ // 2026-05-18 stats-pill relabel: three new optional groups (scanned,
489
+ // changed, views_refreshed) tail the unavailable/not_found block so old log
490
+ // lines still parse via the existing positional regex. Each is independently
491
+ // optional so a partial roll-out (just scanned, just changed) also parses.
492
+ 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
493
 
482
494
  // posts.platform is lowercase; UI labels are capitalized.
483
495
  const PLATFORM_LABELS = {
@@ -586,7 +598,7 @@ function parseRunMonitorLog(maxLines) {
586
598
  for (const line of tail) {
587
599
  const m = line.match(RUN_LINE_RE);
588
600
  if (!m) continue;
589
- const [, ts, script, posted, skipped, failed, repliesRefreshed, checked, updated, removed, unavailable, notFound, salvaged, discoverStr, scanStr, cost, elapsed, failureReasonsStr, skipReasonsStr] = m;
601
+ const [, ts, script, posted, skipped, failed, repliesRefreshed, checked, updated, removed, unavailable, notFound, scannedRaw, changedRaw, viewsRefreshedRaw, salvaged, discoverStr, scanStr, cost, elapsed, failureReasonsStr, skipReasonsStr] = m;
590
602
  if (JOB_HISTORY_HIDDEN_SCRIPTS.has(script)) continue;
591
603
  // log_run.py writes naive local-wallclock time (strftime without tz), so
592
604
  // `new Date(ts)` in node interprets it as local on the server. That is
@@ -670,6 +682,13 @@ function parseRunMonitorLog(maxLines) {
670
682
  removed: removed ? parseInt(removed, 10) : 0,
671
683
  unavailable: unavailable ? parseInt(unavailable, 10) : 0,
672
684
  not_found: notFound ? parseInt(notFound, 10) : 0,
685
+ // 2026-05-18 stats-pill relabel: three new fields surface the split
686
+ // between Step 1 view-scrape leg and Step 2 metric-changed leg.
687
+ // Absent on pre-relabel log lines (defaults to 0); renderResultPills
688
+ // falls back to `checked`/`updated` when these are 0.
689
+ scanned: scannedRaw ? parseInt(scannedRaw, 10) : 0,
690
+ changed: changedRaw ? parseInt(changedRaw, 10) : 0,
691
+ views_refreshed: viewsRefreshedRaw ? parseInt(viewsRefreshedRaw, 10) : 0,
673
692
  salvaged: salvaged ? parseInt(salvaged, 10) : 0,
674
693
  discover, // {} when no `discover=` segment was present on the line
675
694
  scan, // {} when no `scan=` segment was present on the line
@@ -1091,6 +1110,20 @@ function parseTwitterBatchIdMs(batchId) {
1091
1110
  return new Date(`${y}-${mo}-${d}T${hh}:${mm}:${ss}`).getTime();
1092
1111
  }
1093
1112
 
1113
+ // Parse a reddit cycle batch_id (`rdcycle-YYYYMMDD-HHMMSS`) into epoch ms.
1114
+ // Mirrors parseTwitterBatchIdMs. Used to attribute reddit_candidates +
1115
+ // posts back to the cycle that owns them, so styles_used / posted counts
1116
+ // reflect THIS run's work, not whatever concurrent reddit cycles posted
1117
+ // during the same wall-clock window (the prior window-based approach
1118
+ // double-counted styles on overlapping 22-min cycles).
1119
+ function parseRedditBatchIdMs(batchId) {
1120
+ if (!batchId) return NaN;
1121
+ const m = batchId.match(/^rdcycle-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/);
1122
+ if (!m) return NaN;
1123
+ const [, y, mo, d, hh, mm, ss] = m;
1124
+ return new Date(`${y}-${mo}-${d}T${hh}:${mm}:${ss}`).getTime();
1125
+ }
1126
+
1094
1127
  async function enrichPostCommentsTwitterRuns(runs) {
1095
1128
  const txRuns = runs.filter(r =>
1096
1129
  r.job_type === 'post-comments' && r.platform_key === 'twitter'
@@ -1130,7 +1163,8 @@ async function enrichPostCommentsTwitterRuns(runs) {
1130
1163
  // otherwise undercount the per-run queue snapshot at older runs.
1131
1164
  const candidateRows = await pq(
1132
1165
  "SELECT discovered_at, posted_at, t1_checked_at, drafted_at, " +
1133
- " (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id " +
1166
+ " (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id, " +
1167
+ " matched_project, tweet_url " +
1134
1168
  "FROM twitter_candidates " +
1135
1169
  "WHERE discovered_at >= $1::timestamp OR posted_at >= $1::timestamp OR t1_checked_at >= $1::timestamp OR status='pending'",
1136
1170
  [since]
@@ -1164,12 +1198,23 @@ async function enrichPostCommentsTwitterRuns(runs) {
1164
1198
  " )"
1165
1199
  );
1166
1200
  const salvageableNow = (salvageableRow && salvageableRow[0]) ? salvageableRow[0].n : 0;
1201
+ // Bulk-fetch twitter/x posts in window to compute per-run style breakdown.
1202
+ const twitterPostRows = await pq(
1203
+ "SELECT posted_at, thread_url, engagement_style FROM posts " +
1204
+ "WHERE platform IN ('twitter', 'x') AND posted_at >= $1::timestamp AND engagement_style IS NOT NULL",
1205
+ [since]
1206
+ ) || [];
1167
1207
 
1168
1208
  const toMs = (d) => {
1169
1209
  if (!d) return null;
1170
1210
  const dt = d instanceof Date ? d : new Date(d);
1171
1211
  return dt.getTime();
1172
1212
  };
1213
+ const twitterPostNorm = twitterPostRows.map(r => ({
1214
+ postedMs: toMs(r.posted_at),
1215
+ threadUrl: r.thread_url || '',
1216
+ style: r.engagement_style || '',
1217
+ }));
1173
1218
  const searchNorm = searchRows.map(r => ({
1174
1219
  ms: toMs(r.ran_at),
1175
1220
  found: r.tweets_found || 0,
@@ -1194,6 +1239,8 @@ async function enrichPostCommentsTwitterRuns(runs) {
1194
1239
  exitMs,
1195
1240
  status: r.status,
1196
1241
  batch_id: r.batch_id || '',
1242
+ matched_project: r.matched_project || '',
1243
+ tweet_url: r.tweet_url || '',
1197
1244
  };
1198
1245
  });
1199
1246
 
@@ -1235,9 +1282,25 @@ async function enrichPostCommentsTwitterRuns(runs) {
1235
1282
  }
1236
1283
  let candidatesPassed = 0;
1237
1284
  let salvagePosted = 0;
1285
+ // Project labels this cycle actually worked on, surfaced at the end of the
1286
+ // pill row (mirrors enrichPostCommentsRedditRuns). Source is the
1287
+ // twitter_candidates.matched_project values for rows tied to this run's
1288
+ // own batch_id — i.e. tweets Phase 1 scraped + scored on behalf of these
1289
+ // projects. Insertion-order Set preserves first-seen order while deduping.
1290
+ const projectsSeen = new Set();
1291
+ const projectsList = [];
1292
+ const recordProject = (raw) => {
1293
+ if (!raw) return;
1294
+ const proj = raw.trim();
1295
+ if (proj && !projectsSeen.has(proj)) {
1296
+ projectsSeen.add(proj);
1297
+ projectsList.push(proj);
1298
+ }
1299
+ };
1238
1300
  for (const c of candNorm) {
1239
1301
  if (!ownBatchId || c.batch_id !== ownBatchId) continue;
1240
1302
  candidatesPassed++;
1303
+ recordProject(c.matched_project);
1241
1304
  if (c.status === 'posted') {
1242
1305
  posted++;
1243
1306
  // Salvage signature: candidate's discovered_at predates this cycle's
@@ -1270,6 +1333,19 @@ async function enrichPostCommentsTwitterRuns(runs) {
1270
1333
  const body = fs.readFileSync(path.join(LOG_DIR, chosenLog), 'utf8');
1271
1334
  const m = body.match(phase0SalvageRe);
1272
1335
  if (m) salvageAttempted = parseInt(m[1], 10);
1336
+ // Fallback for scan-only cycles where 0 candidates upserted (so
1337
+ // matched_project never landed in twitter_candidates for this batch):
1338
+ // pull project names from the cycle log's "Selected projects:" header.
1339
+ // This surfaces the projects the cycle SCANNED for, mirroring Reddit's
1340
+ // behaviour of capturing project labels even when nothing posted.
1341
+ if (!projectsList.length) {
1342
+ const selM = body.match(/Selected projects:\s*([^\n]+)/);
1343
+ if (selM) {
1344
+ for (const tok of selM[1].split(',')) {
1345
+ recordProject(tok);
1346
+ }
1347
+ }
1348
+ }
1273
1349
  } catch { /* empty */ }
1274
1350
  }
1275
1351
  // Per-run queue delta. ADD = candidates whose discovered_at fell in this
@@ -1341,6 +1417,30 @@ async function enrichPostCommentsTwitterRuns(runs) {
1341
1417
  }
1342
1418
  }
1343
1419
  }
1420
+ // Style counting: scope to the URLs this batch posted, not the time window.
1421
+ // Time-window approach captured concurrent cycles during long-running cycles.
1422
+ const batchPostedUrls = new Set();
1423
+ for (const c of candNorm) {
1424
+ if (ownBatchId && c.batch_id === ownBatchId && c.status === 'posted' && c.tweet_url) {
1425
+ batchPostedUrls.add(c.tweet_url);
1426
+ }
1427
+ }
1428
+ const stylesMapTx = {};
1429
+ for (const p of twitterPostNorm) {
1430
+ if (!p.threadUrl || !batchPostedUrls.has(p.threadUrl)) continue;
1431
+ stylesMapTx[p.style] = (stylesMapTx[p.style] || 0) + 1;
1432
+ }
1433
+ // Fall back to time-window if batch URL matching found nothing (e.g. old
1434
+ // rows before tweet_url was added to the SELECT, or cycle with no ownBatchId).
1435
+ if (!Object.keys(stylesMapTx).length && !ownBatchId) {
1436
+ for (const p of twitterPostNorm) {
1437
+ if (p.postedMs == null || p.postedMs < startMs || p.postedMs > endMs) continue;
1438
+ stylesMapTx[p.style] = (stylesMapTx[p.style] || 0) + 1;
1439
+ }
1440
+ }
1441
+ const stylesUsedTx = Object.entries(stylesMapTx)
1442
+ .sort(function (a, b) { return b[1] - a[1]; })
1443
+ .map(function (e) { return e[0] + '(' + e[1] + ')'; });
1344
1444
  const prior = run.result || {};
1345
1445
  const priorDiscover = (prior.discover && typeof prior.discover === 'object') ? prior.discover : {};
1346
1446
  run.result = {
@@ -1373,6 +1473,13 @@ async function enrichPostCommentsTwitterRuns(runs) {
1373
1473
  salvage_attempted: salvageAttempted,
1374
1474
  salvage_posted: salvagePosted,
1375
1475
  own_batch_id: ownBatchId,
1476
+ // Project(s) this cycle actually worked on (Phase 1 scraped + scored
1477
+ // candidates for), parsed from twitter_candidates.matched_project for
1478
+ // rows tied to this run's batch_id. Surfaces at the end of the dashboard
1479
+ // pill row so the operator can see at a glance which projects consumed
1480
+ // the cycle, even when posted=0. Mirrors enrichPostCommentsRedditRuns.
1481
+ projects_worked: projectsList,
1482
+ styles_used: stylesUsedTx,
1376
1483
  cost_usd: prior.cost_usd || 0,
1377
1484
  failed: prior.failed || 0,
1378
1485
  failure_reasons: Array.isArray(prior.failure_reasons) ? prior.failure_reasons : [],
@@ -1430,7 +1537,8 @@ async function enrichPostCommentsRedditRuns(runs) {
1430
1537
  const since = new Date(oldestMs - 2 * 60 * 1000).toISOString();
1431
1538
  const candidateRows = await pq(
1432
1539
  "SELECT discovered_at, posted_at, last_attempt_at, t1_checked_at, drafted_at, " +
1433
- " attempt_count, (draft_text IS NOT NULL) AS has_draft, status, batch_id " +
1540
+ " attempt_count, (draft_text IS NOT NULL) AS has_draft, status, batch_id, " +
1541
+ " post_id " +
1434
1542
  "FROM reddit_candidates " +
1435
1543
  "WHERE discovered_at >= $1::timestamp " +
1436
1544
  " OR posted_at >= $1::timestamp " +
@@ -1456,12 +1564,26 @@ async function enrichPostCommentsRedditRuns(runs) {
1456
1564
  " )"
1457
1565
  );
1458
1566
  const salvageableNow = (salvageableRow && salvageableRow[0]) ? salvageableRow[0].n : 0;
1567
+ // Bulk-fetch reddit posts in window to compute per-run style breakdown.
1568
+ // posts.id is included so the enricher can scope styles to "posts whose id
1569
+ // appears as reddit_candidates.post_id for this run's own batch_id",
1570
+ // mirroring the Twitter batch-scoped style attribution.
1571
+ const redditPostRows = await pq(
1572
+ "SELECT id, posted_at, engagement_style FROM posts " +
1573
+ "WHERE platform = 'reddit' AND posted_at >= $1::timestamp AND engagement_style IS NOT NULL",
1574
+ [since]
1575
+ ) || [];
1459
1576
 
1460
1577
  const toMs = (d) => {
1461
1578
  if (!d) return null;
1462
1579
  const dt = d instanceof Date ? d : new Date(d);
1463
1580
  return dt.getTime();
1464
1581
  };
1582
+ const redditPostNorm = redditPostRows.map(r => ({
1583
+ id: r.id,
1584
+ postedMs: toMs(r.posted_at),
1585
+ style: r.engagement_style || '',
1586
+ }));
1465
1587
  // exitMs = the moment the row left 'pending'. Null for rows still pending.
1466
1588
  // posted -> posted_at
1467
1589
  // failed -> last_attempt_at (set by _db_mark_candidate_attempt + html_locked)
@@ -1491,6 +1613,7 @@ async function enrichPostCommentsRedditRuns(runs) {
1491
1613
  exitMs,
1492
1614
  status: r.status,
1493
1615
  batch_id: r.batch_id || '',
1616
+ post_id: r.post_id || null,
1494
1617
  };
1495
1618
  });
1496
1619
  // Filename carries the run start: run-reddit-search-YYYY-MM-DD_HHMMSS.log
@@ -1799,6 +1922,57 @@ async function enrichPostCommentsRedditRuns(runs) {
1799
1922
  + queueDrainedExpired + queueDrainedSkipped;
1800
1923
 
1801
1924
  const dropped = Math.max(0, raw - passed);
1925
+ // Style counting: scope to the post_ids THIS batch posted, not the time
1926
+ // window. Mirrors the Twitter fix (server.js ~line 1408). Window-based
1927
+ // attribution double-counted styles during long-running cycles because
1928
+ // reddit-search overlaps every 15 min and salvage/discover lanes from
1929
+ // OTHER projects post inside the same wall-clock window.
1930
+ //
1931
+ // Derive ownBatchId by matching rdcycle-YYYYMMDD-HHMMSS to run.started_at
1932
+ // within ±10s. Fall back to the cycle log header `Cycle batch_id=...`
1933
+ // (emitted by run-reddit-search.sh:191) when no candidate batch matches,
1934
+ // so cycles that scored 0 raw → 0 candidates still attribute their styles.
1935
+ let ownBatchId = null;
1936
+ let ownBatchDelta = Infinity;
1937
+ for (const c of candNorm) {
1938
+ if (!c.batch_id) continue;
1939
+ const bms = parseRedditBatchIdMs(c.batch_id);
1940
+ if (!Number.isFinite(bms)) continue;
1941
+ const delta = Math.abs(bms - startMs);
1942
+ if (delta > 10 * 1000) continue;
1943
+ if (delta < ownBatchDelta) { ownBatchDelta = delta; ownBatchId = c.batch_id; }
1944
+ }
1945
+ if (!ownBatchId) {
1946
+ const logHeader = body.match(/Cycle batch_id=(rdcycle-\d{8}-\d{6})/);
1947
+ if (logHeader) ownBatchId = logHeader[1];
1948
+ }
1949
+ // Build a Set of posts.id values that THIS batch's reddit_candidates
1950
+ // rows have stamped as their post_id (the posts row created by
1951
+ // post_reddit.py when status flipped to 'posted').
1952
+ const batchPostedIds = new Set();
1953
+ for (const c of candNorm) {
1954
+ if (ownBatchId && c.batch_id === ownBatchId
1955
+ && c.status === 'posted' && c.post_id != null) {
1956
+ batchPostedIds.add(c.post_id);
1957
+ }
1958
+ }
1959
+ const stylesMapRd = {};
1960
+ for (const p of redditPostNorm) {
1961
+ if (p.id == null || !batchPostedIds.has(p.id)) continue;
1962
+ stylesMapRd[p.style] = (stylesMapRd[p.style] || 0) + 1;
1963
+ }
1964
+ // Fall back to time-window when batch-scoped lookup found nothing AND we
1965
+ // failed to identify ownBatchId (very old rows, or cycle log missing the
1966
+ // header). Preserves visibility for runs with no batch correlation.
1967
+ if (!Object.keys(stylesMapRd).length && !ownBatchId) {
1968
+ for (const p of redditPostNorm) {
1969
+ if (p.postedMs == null || p.postedMs < startMs || p.postedMs > endMs) continue;
1970
+ stylesMapRd[p.style] = (stylesMapRd[p.style] || 0) + 1;
1971
+ }
1972
+ }
1973
+ const stylesUsedRd = Object.entries(stylesMapRd)
1974
+ .sort(function (a, b) { return b[1] - a[1]; })
1975
+ .map(function (e) { return e[0] + '(' + e[1] + ')'; });
1802
1976
  const prior = run.result || {};
1803
1977
  // Trust the per-iter rollup `phase=post posted=N` over the bare POSTED:
1804
1978
  // grep when both exist (POSTED: can fire mid-retry). Fall back to the
@@ -1833,6 +2007,11 @@ async function enrichPostCommentsRedditRuns(runs) {
1833
2007
  // end of the dashboard pill row so the operator can see at a glance which
1834
2008
  // project(s) consumed the cycle (often 2 distinct: salvage lane + discover).
1835
2009
  projects_worked: projectsList,
2010
+ styles_used: stylesUsedRd,
2011
+ // Exposed for parity with Twitter's enricher result; lets the dashboard
2012
+ // (or `/api/...`) show which rdcycle batch this run "owns" for debugging
2013
+ // batch-scoped attribution misses.
2014
+ own_batch_id: ownBatchId,
1836
2015
  // Ripen phase (5-min delta gate, scripts/ripen_reddit_plan.py). Reflects
1837
2016
  // the per-run sum across all iterations that reached the ripen step.
1838
2017
  // ripen_iters counts iterations where the [ripen] summary marker fired
@@ -2224,7 +2403,7 @@ async function enrichSeoRuns(runs) {
2224
2403
  }
2225
2404
  if (sessionIds.length) {
2226
2405
  const rows = await pq(
2227
- 'SELECT session_id, total_cost_usd, orchestrator_cost_usd FROM claude_sessions WHERE session_id = ANY($1::uuid[])',
2406
+ 'SELECT session_id, total_cost_usd, orchestrator_cost_usd, subagent_cost_usd FROM claude_sessions WHERE session_id = ANY($1::uuid[])',
2228
2407
  [sessionIds]
2229
2408
  );
2230
2409
  if (rows && rows.length) {
@@ -2234,6 +2413,9 @@ async function enrichSeoRuns(runs) {
2234
2413
  orchestrator: r.orchestrator_cost_usd != null
2235
2414
  ? Number(r.orchestrator_cost_usd)
2236
2415
  : null,
2416
+ subagent: r.subagent_cost_usd != null
2417
+ ? Number(r.subagent_cost_usd)
2418
+ : null,
2237
2419
  }])
2238
2420
  );
2239
2421
  for (const run of seoRuns) {
@@ -2246,16 +2428,20 @@ async function enrichSeoRuns(runs) {
2246
2428
  // sessions, locked callers that don't pass --orchestrator-cost-usd),
2247
2429
  // keep whatever streamRes value _collectSeoDetails parsed from the
2248
2430
  // .log file.
2431
+ // SDK-only: d.cost_usd is orchestrator_cost_usd alone. Estimate
2432
+ // and subagent are kept on the row for diagnostic tooltips but
2433
+ // not folded into the displayed total.
2249
2434
  if (Number.isFinite(row.orchestrator)) {
2250
2435
  d.cost_usd = row.orchestrator;
2251
2436
  d.cost_usd_orchestrator = row.orchestrator;
2252
- } else if (Number.isFinite(d.cost_usd)) {
2253
- d.cost_usd_orchestrator = d.cost_usd;
2437
+ } else {
2438
+ d.cost_usd = null;
2439
+ d.cost_usd_orchestrator = null;
2254
2440
  }
2255
- // Manual estimate alongside (always populated by log_claude_session.py).
2256
2441
  if (Number.isFinite(row.estimated)) {
2257
2442
  d.cost_usd_estimated = row.estimated;
2258
2443
  }
2444
+ d.cost_usd_subagent = Number.isFinite(row.subagent) ? row.subagent : 0;
2259
2445
  }
2260
2446
  }
2261
2447
  }
@@ -2284,6 +2470,229 @@ async function enrichSeoRuns(runs) {
2284
2470
  }
2285
2471
  }
2286
2472
 
2473
+ // Maps a Job History row's `script` (log_run.py canonical name) to the
2474
+ // `claude_sessions.script` values its wrapper actually spawns. Without this
2475
+ // constraint a 40-min post_reddit run's window catches every concurrent
2476
+ // pipeline's sessions (engage-dm-replies, seo_generate_page, etc.) and the
2477
+ // "cost" cell sums everything that ran in parallel — meaningless. Lookup is
2478
+ // keyed on the underscore form (post_reddit, dm_replies_reddit) that survives
2479
+ // classifyScript's normalization. Add new entries here when a new wrapper
2480
+ // pipeline ships.
2481
+ const _PHASE_FAMILY = {
2482
+ // Reddit
2483
+ post_reddit: ['post_reddit'],
2484
+ engage_reddit: ['engage_reddit'],
2485
+ thread_reddit: ['run-reddit-threads'],
2486
+ dm_outreach_reddit: ['dm-outreach-reddit'],
2487
+ dm_replies_reddit: ['engage-dm-replies'],
2488
+ link_edit_reddit: ['link-edit-reddit'],
2489
+ // Twitter
2490
+ post_twitter: ['run-twitter-cycle-scan', 'run-twitter-cycle-prep'],
2491
+ engage_twitter: ['engage-twitter-phaseB'],
2492
+ thread_twitter: ['run-twitter-threads'],
2493
+ dm_outreach_twitter: ['dm-outreach-twitter'],
2494
+ dm_replies_twitter: ['engage-dm-replies'],
2495
+ link_edit_twitter: ['link-edit-twitter'],
2496
+ // LinkedIn (run-linkedin bare = pre-phase-split legacy tag, kept for old rows)
2497
+ post_linkedin: ['run-linkedin-phaseA', 'run-linkedin-phaseB', 'run-linkedin'],
2498
+ engage_linkedin: ['engage-linkedin-phaseA', 'engage-linkedin-phaseB'],
2499
+ dm_replies_linkedin: ['engage-dm-replies'],
2500
+ dm_outreach_linkedin: ['dm-outreach-linkedin'],
2501
+ link_edit_linkedin: ['link-edit-linkedin'],
2502
+ // GitHub
2503
+ post_github: ['post_github'],
2504
+ engage_github: ['engage_github', 'github-engage', 'run-github-cycle'],
2505
+ link_edit_github: ['link-edit-github'],
2506
+ // Moltbook
2507
+ post_moltbook: ['run-moltbook-cycle'],
2508
+ // SEO
2509
+ gsc_seo: ['seo_generate_page', 'seo_generate_page_retry'],
2510
+ serp_seo: ['seo_generate_page', 'seo_generate_page_retry'],
2511
+ seo_improve: ['seo_improve_page'],
2512
+ seo_weekly_roundup: ['seo_generate_page'],
2513
+ seo_top_pages: ['seo_generate_page'],
2514
+ seo_top_posts: ['seo_generate_page'],
2515
+ };
2516
+
2517
+ function _phaseFamilyFor(runScript) {
2518
+ if (!runScript) return null;
2519
+ const norm = String(runScript).replace(/-/g, '_').toLowerCase();
2520
+ const aliased = SCRIPT_ALIASES[norm] || norm;
2521
+ const family = _PHASE_FAMILY[aliased];
2522
+ return family && family.length ? new Set(family) : null;
2523
+ }
2524
+
2525
+ // Per-phase Claude cost breakdown for Job History rows. For each completed
2526
+ // run, queries `claude_sessions` rows whose `script` belongs to the run's
2527
+ // known phase family (see _PHASE_FAMILY) AND whose `started_at` falls inside
2528
+ // [run.started_at - slack, run.finished_at + slack]. Groups results by
2529
+ // session script (the phase) and attaches:
2530
+ // run.result.cost_breakdown = {
2531
+ // total, orchestrator, subagent, estimated,
2532
+ // phases: [{phase, sessions, total, orch, sub, est}, ...] // desc by total
2533
+ // }
2534
+ // run.result.cost_usd = total (overrides the shell-log value when
2535
+ // we found ≥1 matching session — the wrapper
2536
+ // log line often misses sub-phase cost,
2537
+ // e.g. engage_twitter logs $0 but phaseB
2538
+ // spent real money).
2539
+ // run.result.cost_usd_from_log = original shell-log value (preserved for
2540
+ // audit/provenance).
2541
+ // run.result.cost_usd_* = orch/subagent/estimated lanes for the
2542
+ // 4-lane tooltip the Cost column renders.
2543
+ // Runs whose `script` isn't in _PHASE_FAMILY get NO breakdown (we don't know
2544
+ // which sessions to attribute) and keep their shell-log cost.
2545
+ //
2546
+ // cycle_id disambiguation (2026-05-15): time-window + family matching alone
2547
+ // mis-attributes overlapping cycles. Twitter cycles fire every 15 min, so a
2548
+ // 30-min run's window catches the previous cycle's late-running prep and the
2549
+ // next cycle's scan. Each wrapper invocation exports a unique SA_CYCLE_ID
2550
+ // that every child claude_sessions row inherits, so after window+family
2551
+ // matching we pick the ONE cycle_id whose earliest session is closest to the
2552
+ // run's started_at (that's the run's own scan/first phase) and drop every
2553
+ // session belonging to a different cycle_id. Sessions with NULL cycle_id are
2554
+ // only used when the run matched no cycle_id at all (pure fallback).
2555
+ async function enrichRunsCostBreakdown(runs) {
2556
+ const candidates = (runs || []).filter(r =>
2557
+ !r.running && r.started_at && r.finished_at && _phaseFamilyFor(r.script)
2558
+ );
2559
+ if (!candidates.length) return;
2560
+ let minStart = Infinity, maxEnd = 0;
2561
+ for (const r of candidates) {
2562
+ const s = Date.parse(r.started_at);
2563
+ const e = Date.parse(r.finished_at);
2564
+ if (Number.isFinite(s) && s < minStart) minStart = s;
2565
+ if (Number.isFinite(e) && e > maxEnd) maxEnd = e;
2566
+ }
2567
+ if (minStart === Infinity) return;
2568
+ const slackMs = _RUN_WINDOW_SLACK_MS;
2569
+ const since = new Date(minStart - slackMs).toISOString();
2570
+ const until = new Date(maxEnd + slackMs).toISOString();
2571
+ let rows;
2572
+ try {
2573
+ rows = await pq(
2574
+ // SDK-only mode: orchestrator_cost_usd is the only number we display.
2575
+ // Subagent and transcript-estimate are kept for diagnostic tooltips but
2576
+ // never added into the total. has_sdk lets the UI distinguish "session
2577
+ // happened but SDK didn't capture cost" from "session cost was $0".
2578
+ "SELECT script, started_at, cycle_id, " +
2579
+ "orchestrator_cost_usd IS NOT NULL AS has_sdk, " +
2580
+ "COALESCE(orchestrator_cost_usd, 0)::float8 AS orch, " +
2581
+ "COALESCE(total_cost_usd, 0)::float8 AS est, " +
2582
+ "COALESCE(subagent_cost_usd, 0)::float8 AS sub " +
2583
+ "FROM claude_sessions WHERE started_at BETWEEN $1::timestamp AND $2::timestamp",
2584
+ [since, until]
2585
+ );
2586
+ } catch (e) {
2587
+ console.error('[enrichRunsCostBreakdown] query failed:', e && e.message || e);
2588
+ return;
2589
+ }
2590
+ if (!rows || !rows.length) return;
2591
+ const sessionList = rows.map(r => ({
2592
+ ts: r.started_at instanceof Date ? r.started_at.getTime() : Date.parse(r.started_at),
2593
+ script: r.script || '(unknown)',
2594
+ cycleId: r.cycle_id || null,
2595
+ hasSdk: !!r.has_sdk,
2596
+ orch: Number(r.orch) || 0,
2597
+ est: Number(r.est) || 0,
2598
+ sub: Number(r.sub) || 0,
2599
+ // SDK-only: total = orch. Sessions without SDK contribute 0 to total
2600
+ // (they're flagged via hasSdk so the tooltip can show coverage).
2601
+ total: Number(r.orch) || 0,
2602
+ }));
2603
+ for (const r of candidates) {
2604
+ const runStartMs = Date.parse(r.started_at);
2605
+ const startMs = runStartMs - slackMs;
2606
+ const endMs = Date.parse(r.finished_at) + slackMs;
2607
+ const family = _phaseFamilyFor(r.script);
2608
+ // Step 1: window + family match (over-broad — catches overlapping cycles).
2609
+ const windowMatched = sessionList.filter(s =>
2610
+ s.ts >= startMs && s.ts <= endMs && (!family || family.has(s.script))
2611
+ );
2612
+ // Step 2: cycle_id disambiguation. Group matched sessions by cycle_id and
2613
+ // pick the group whose earliest session starts closest to this run's
2614
+ // started_at — that's the run's own cycle. Drop sessions from other
2615
+ // cycle_ids. If no session carries a cycle_id, fall back to the full
2616
+ // window-matched set (older sessions / pipelines that don't set
2617
+ // SA_CYCLE_ID).
2618
+ let attributed;
2619
+ const byCycle = new Map();
2620
+ for (const s of windowMatched) {
2621
+ if (!s.cycleId) continue;
2622
+ let g = byCycle.get(s.cycleId);
2623
+ if (!g) { g = { earliest: s.ts, sessions: [] }; byCycle.set(s.cycleId, g); }
2624
+ if (s.ts < g.earliest) g.earliest = s.ts;
2625
+ g.sessions.push(s);
2626
+ }
2627
+ if (byCycle.size > 0) {
2628
+ let bestCycle = null, bestDelta = Infinity;
2629
+ for (const [cid, g] of byCycle) {
2630
+ const delta = Math.abs(g.earliest - runStartMs);
2631
+ if (delta < bestDelta) { bestDelta = delta; bestCycle = cid; }
2632
+ }
2633
+ attributed = byCycle.get(bestCycle).sessions;
2634
+ } else {
2635
+ attributed = windowMatched;
2636
+ }
2637
+ const byPhase = {};
2638
+ let totalOrch = 0, totalEst = 0, totalSub = 0;
2639
+ let sessionsAll = 0, sessionsWithSdk = 0;
2640
+ for (const s of attributed) {
2641
+ const key = s.script;
2642
+ const cur = byPhase[key] || { phase: key, sessions: 0, sessions_with_sdk: 0, orch: 0, est: 0, sub: 0 };
2643
+ cur.sessions += 1;
2644
+ if (s.hasSdk) cur.sessions_with_sdk += 1;
2645
+ cur.orch += s.orch;
2646
+ cur.est += s.est;
2647
+ cur.sub += s.sub;
2648
+ byPhase[key] = cur;
2649
+ sessionsAll += 1;
2650
+ if (s.hasSdk) sessionsWithSdk += 1;
2651
+ totalOrch += s.orch;
2652
+ totalEst += s.est;
2653
+ totalSub += s.sub;
2654
+ }
2655
+ if (!sessionsAll) continue;
2656
+ const phases = Object.values(byPhase)
2657
+ .sort((a, b) => b.orch - a.orch || b.sessions - a.sessions)
2658
+ .map(p => ({
2659
+ phase: p.phase,
2660
+ sessions: p.sessions,
2661
+ sessions_with_sdk: p.sessions_with_sdk,
2662
+ sessions_missing_sdk: p.sessions - p.sessions_with_sdk,
2663
+ total: Number(p.orch.toFixed(6)),
2664
+ orch: Number(p.orch.toFixed(6)),
2665
+ sub: Number(p.sub.toFixed(6)),
2666
+ est: Number(p.est.toFixed(6)),
2667
+ }));
2668
+ if (!r.result) r.result = {};
2669
+ // Preserve provenance: the original shell-log cost (parseFloat'd from the
2670
+ // run_monitor line) is kept under cost_usd_from_log so an operator can
2671
+ // see what the wrapper reported vs what we recomputed.
2672
+ if (typeof r.result.cost_usd === 'number') {
2673
+ r.result.cost_usd_from_log = r.result.cost_usd;
2674
+ }
2675
+ // SDK-only displayed total. Sessions missing SDK contribute 0 — the
2676
+ // sessions_missing_sdk counter on each phase row is the only signal that
2677
+ // real spend went unrecorded.
2678
+ r.result.cost_breakdown = {
2679
+ total: Number(totalOrch.toFixed(6)),
2680
+ orchestrator: Number(totalOrch.toFixed(6)),
2681
+ subagent: Number(totalSub.toFixed(6)),
2682
+ estimated: Number(totalEst.toFixed(6)),
2683
+ sessions: sessionsAll,
2684
+ sessions_with_sdk: sessionsWithSdk,
2685
+ sessions_missing_sdk: sessionsAll - sessionsWithSdk,
2686
+ phases,
2687
+ };
2688
+ r.result.cost_usd = r.result.cost_breakdown.total;
2689
+ r.result.cost_usd_orchestrator = r.result.cost_breakdown.orchestrator;
2690
+ r.result.cost_usd_estimated = r.result.cost_breakdown.estimated;
2691
+ r.result.cost_usd_subagent = r.result.cost_breakdown.subagent;
2692
+ r.result.cost_sessions_missing_sdk = r.result.cost_breakdown.sessions_missing_sdk;
2693
+ }
2694
+ }
2695
+
2287
2696
  // DB-backed enrichment uses run.started_at and run.finished_at to define a
2288
2697
  // window, plus a small slack on each side for clock skew between the shell
2289
2698
  // trap that wrote the run_monitor line and the page row's completed_at.
@@ -3526,6 +3935,7 @@ async function handleApi(req, res) {
3526
3935
  await enrichPostCommentsTwitterRuns(runs);
3527
3936
  await enrichPostCommentsRedditRuns(runs);
3528
3937
  await enrichSeoRuns(runs);
3938
+ await enrichRunsCostBreakdown(runs);
3529
3939
  // Prepend in-progress pipelines so they appear at the top of the table.
3530
3940
  // Always included regardless of the hours window — a long-running job
3531
3941
  // started before the window is still relevant right now.
@@ -3632,28 +4042,37 @@ async function handleApi(req, res) {
3632
4042
  "), session_counts AS (" +
3633
4043
  "SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id" +
3634
4044
  "), session_cost AS (" +
4045
+ // SDK-only mode (2026-05-15): per_row_cost = orchestrator_cost_usd
4046
+ // alone, split evenly across activity rows. NULL when the wrapper
4047
+ // didn't pass --orchestrator-cost-usd (e.g. shell wrappers that omit
4048
+ // --output-format json so Claude never emits total_cost_usd). The
4049
+ // transcript estimate and subagent dollars are computed from a local
4050
+ // pricing table — kept in the JSON payload for diagnostics only,
4051
+ // never folded into the displayed total.
3635
4052
  "SELECT cs.session_id, " +
3636
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost, " +
4053
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost, " +
3637
4054
  "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_orchestrator, " +
3638
- "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_estimated " +
4055
+ "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_estimated, " +
4056
+ "(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_subagent " +
3639
4057
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id" +
3640
4058
  ") " +
3641
4059
  "SELECT json_agg(row_to_json(r)) FROM (" +
3642
- "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 " +
3643
- "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 " +
3644
- "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 " +
3645
- "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 " +
3646
- "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 " +
3647
- "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 " +
3648
- "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 " +
3649
- "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 " +
3650
- "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 " +
3651
- "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 " +
3652
- "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 " +
3653
- "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 " +
3654
- "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 " +
3655
- "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 " +
3656
- "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 " +
4060
+ "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 " +
4061
+ "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 " +
4062
+ "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 " +
4063
+ "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 " +
4064
+ "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 " +
4065
+ "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 " +
4066
+ "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 " +
4067
+ "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 " +
4068
+ "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 " +
4069
+ "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 " +
4070
+ "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 " +
4071
+ "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 " +
4072
+ "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 " +
4073
+ "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 " +
4074
+ "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 " +
4075
+ "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 " +
3657
4076
  "ORDER BY 1 DESC LIMIT 500) r";
3658
4077
  return (async () => {
3659
4078
  const rows = await pq(q);
@@ -3668,6 +4087,7 @@ async function handleApi(req, res) {
3668
4087
  delete e.cost_usd;
3669
4088
  delete e.cost_usd_orchestrator;
3670
4089
  delete e.cost_usd_estimated;
4090
+ delete e.cost_usd_subagent;
3671
4091
  });
3672
4092
  }
3673
4093
  return json(res, { events });
@@ -3866,7 +4286,7 @@ async function handleApi(req, res) {
3866
4286
  // Project is case-sensitive (stored as 'Assrt', 'Cyrano', 'fazm', etc.).
3867
4287
  const rawProject = (url.searchParams.get('project') || '').trim();
3868
4288
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
3869
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4289
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
3870
4290
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
3871
4291
  // Non-admin clients can only see projects in their claim. Reject if the
3872
4292
  // requested project isn't allowed, and force-filter the default "all" view.
@@ -3918,18 +4338,23 @@ async function handleApi(req, res) {
3918
4338
  "ELSE COALESCE(upvotes,0) END), 0)::int AS upvotes_discounted, " +
3919
4339
  "COALESCE(SUM(comments_count), 0)::int AS comments, " +
3920
4340
  "COALESCE(SUM(views) FILTER (WHERE LOWER(platform) NOT IN ('moltbook', 'github', 'github_issues')), 0)::int AS views, " +
3921
- // post_clicks: SUM of post_links.clicks attributable to short links
3922
- // minted for these posts (post_id-keyed). Reply-keyed clicks are
3923
- // excluded so we don't double-count engagement on replies that hang
3924
- // off someone else's thread.
4341
+ // post_clicks: bot-filtered click events from post_link_clicks for
4342
+ // short links minted for these posts (post_id-keyed). Reply-keyed
4343
+ // clicks are excluded so we don't double-count engagement on replies
4344
+ // that hang off someone else's thread. Source matches the picker
4345
+ // (engagement_styles._fetch_style_stats) and top_performers.SCORE_SQL.
3925
4346
  "COALESCE(SUM(pl.total_clicks), 0)::int AS post_clicks, " +
3926
4347
  // Intent dimension (is_recommendation) is independent of tone (engagement_style).
3927
4348
  // This sum tells us "of N posts in this tone, how many carried a project mention".
3928
4349
  "COALESCE(SUM(CASE WHEN is_recommendation THEN 1 ELSE 0 END), 0)::int AS recommendations " +
3929
4350
  "FROM posts " +
3930
4351
  "LEFT JOIN (" +
3931
- "SELECT post_id, SUM(clicks)::int AS total_clicks " +
3932
- "FROM post_links WHERE post_id IS NOT NULL GROUP BY post_id" +
4352
+ "SELECT pl2.post_id, COUNT(plc.id)::int AS total_clicks " +
4353
+ "FROM post_links pl2 " +
4354
+ "LEFT JOIN post_link_clicks plc " +
4355
+ "ON plc.code = pl2.code AND plc.is_bot = false " +
4356
+ "WHERE pl2.post_id IS NOT NULL " +
4357
+ "GROUP BY pl2.post_id" +
3933
4358
  ") pl ON pl.post_id = posts.id " +
3934
4359
  "WHERE posted_at >= NOW() - INTERVAL '" + windowHours + " hours' " +
3935
4360
  "AND our_content <> '(mention - no original post)' " +
@@ -3957,9 +4382,10 @@ async function handleApi(req, res) {
3957
4382
  }
3958
4383
 
3959
4384
  // GET /api/cohort/stats - posts bucketed into 4 score cohorts over a trailing window.
3960
- // Score formula matches top_performers.py SCORE_SQL:
3961
- // score = comments_count*3 + upvotes (Reddit/Moltbook: -1 to strip OP self-upvote)
3962
- // Cohorts: dead=0, low=1-4, mid=5-14, high=15+.
4385
+ // Score formula matches top_performers.py SCORE_SQL and engagement_styles.py picker:
4386
+ // score = clicks*10 + comments_count*3 + upvotes (Reddit/Moltbook: -1 to strip OP self-upvote)
4387
+ // clicks = COUNT(post_link_clicks WHERE is_bot=false) per post (bot-filtered).
4388
+ // Cohorts: dead=0, low=1-9, mid=10-29, high=30+ (rebucketed for the click-weighted score).
3963
4389
  // Honors the same window/platform/project filters as the rest of the Stats tab.
3964
4390
  if (p === '/api/cohort/stats' && req.method === 'GET') {
3965
4391
  const url = new URL(req.url, 'http://localhost');
@@ -3971,7 +4397,7 @@ async function handleApi(req, res) {
3971
4397
  if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
3972
4398
  const rawProject = (url.searchParams.get('project') || '').trim();
3973
4399
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
3974
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4400
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
3975
4401
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
3976
4402
  const cohortPc = auth.projectClause(req.user, 'project_name', project || null);
3977
4403
  if (!cohortPc.ok) return json(res, { windowHours, platform: platform || 'all', project: project || 'all', rows: [], totalPosts: 0 });
@@ -3986,17 +4412,27 @@ async function handleApi(req, res) {
3986
4412
  : '';
3987
4413
  const projectFilter = cohortPc.clause ? cohortPc.clause + ' '
3988
4414
  : (project ? "AND project_name = '" + project.replace(/'/g, "''") + "' " : '');
3989
- // Score expression (must stay aligned with scripts/top_performers.SCORE_SQL).
4415
+ // Score expression (must stay aligned with scripts/top_performers.SCORE_SQL
4416
+ // and scripts/engagement_styles.py picker). clicks weighted ×10 because a
4417
+ // real human click outvalues 10 likes of vibes. Clicks come from a
4418
+ // bot-filtered subquery against post_link_clicks (matching the picker).
3990
4419
  const scoreExpr =
3991
- "(COALESCE(comments_count,0) * 3 + " +
4420
+ "(COALESCE(clicks, 0) * 10 + " +
4421
+ "COALESCE(comments_count,0) * 3 + " +
3992
4422
  "CASE WHEN LOWER(platform) IN ('reddit', 'moltbook') " +
3993
4423
  "THEN GREATEST(0, COALESCE(upvotes,0) - 1) " +
3994
4424
  "ELSE COALESCE(upvotes,0) END)";
4425
+ // Cohort bands rescaled for the click-weighted score so most posts don't
4426
+ // collapse into 'high' (a single click already adds 10). Empirically:
4427
+ // dead = 0 no engagement at all
4428
+ // low = 1-9 some upvotes/comments OR no clicks
4429
+ // mid = 10-29 a click OR strong comment/upvote
4430
+ // high = 30+ clicks + discussion combined
3995
4431
  const cohortExpr =
3996
4432
  "CASE " +
3997
4433
  "WHEN " + scoreExpr + " = 0 THEN 'dead' " +
3998
- "WHEN " + scoreExpr + " BETWEEN 1 AND 4 THEN 'low' " +
3999
- "WHEN " + scoreExpr + " BETWEEN 5 AND 14 THEN 'mid' " +
4434
+ "WHEN " + scoreExpr + " BETWEEN 1 AND 9 THEN 'low' " +
4435
+ "WHEN " + scoreExpr + " BETWEEN 10 AND 29 THEN 'mid' " +
4000
4436
  "ELSE 'high' END";
4001
4437
  // Views excluded from moltbook/github (no public view counter on those
4002
4438
  // platforms); use FILTER so the views range only reflects platforms that
@@ -4025,11 +4461,20 @@ async function handleApi(req, res) {
4025
4461
  "MAX(COALESCE(views,0)) " + viewsFilter + " AS max_views, " +
4026
4462
  "(AVG(COALESCE(views,0)) " + viewsFilter + ")::numeric(10,0) AS avg_views " +
4027
4463
  "FROM (" +
4028
- "SELECT platform, " + upvotesNetExpr + " AS upvotes_net, " +
4464
+ "SELECT posts.platform, " + upvotesNetExpr + " AS upvotes_net, " +
4465
+ "COALESCE(pl.total_clicks, 0) AS clicks, " +
4029
4466
  "comments_count, views, " +
4030
4467
  scoreExpr + " AS score, " +
4031
4468
  cohortExpr + " AS cohort " +
4032
4469
  "FROM posts " +
4470
+ "LEFT JOIN (" +
4471
+ "SELECT pl2.post_id, COUNT(plc.id)::int AS total_clicks " +
4472
+ "FROM post_links pl2 " +
4473
+ "LEFT JOIN post_link_clicks plc " +
4474
+ "ON plc.code = pl2.code AND plc.is_bot = false " +
4475
+ "WHERE pl2.post_id IS NOT NULL " +
4476
+ "GROUP BY pl2.post_id" +
4477
+ ") pl ON pl.post_id = posts.id " +
4033
4478
  "WHERE posted_at >= NOW() - INTERVAL '" + windowHours + " hours' " +
4034
4479
  "AND upvotes IS NOT NULL " +
4035
4480
  "AND our_content <> '(mention - no original post)' " +
@@ -4158,7 +4603,16 @@ async function handleApi(req, res) {
4158
4603
  // started with a prospect."
4159
4604
  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);
4160
4605
  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);
4161
- 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);
4606
+ // Pre-2026-05-16: a single 'page_published_serp' bucket caught every
4607
+ // seo_keywords row whose source was not (reddit, top_page, top_post,
4608
+ // roundup). The real SERP pipeline was unloaded 2026-04-17; the cards
4609
+ // labelled "SERP SEO" since then have actually been Twitter-cycle
4610
+ // page-gen output (twitter_gen_links.py -> generate_page.py --trigger
4611
+ // twitter, source='twitter'). Split into an honest twitter bucket and a
4612
+ // misc catch-all for legacy/edge sources (existing_page, gsc,
4613
+ // suggestion:*, competitor:*, topic_template, etc).
4614
+ 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);
4615
+ 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);
4162
4616
  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);
4163
4617
  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);
4164
4618
  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);
@@ -4209,7 +4663,7 @@ async function handleApi(req, res) {
4209
4663
  if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
4210
4664
  const rawProject = (url.searchParams.get('project') || '').trim();
4211
4665
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4212
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4666
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4213
4667
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4214
4668
  const cacheKey = days + '|' + platform + '|' + project;
4215
4669
  const cached = cache.get(cacheKey);
@@ -4289,7 +4743,7 @@ async function handleApi(req, res) {
4289
4743
  if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
4290
4744
  const rawProject = (url.searchParams.get('project') || '').trim();
4291
4745
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4292
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4746
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4293
4747
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4294
4748
  const cacheKey = days + '|' + platform + '|' + project;
4295
4749
  const cached = clicksPerDayCache.get(cacheKey);
@@ -4341,7 +4795,7 @@ async function handleApi(req, res) {
4341
4795
  if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
4342
4796
  const rawProject = (url.searchParams.get('project') || '').trim();
4343
4797
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4344
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4798
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4345
4799
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4346
4800
  const cacheKey = days + '|' + platform + '|' + project;
4347
4801
  const cached = postsPerDayCache.get(cacheKey);
@@ -4354,10 +4808,18 @@ async function handleApi(req, res) {
4354
4808
  const projectFilter = project
4355
4809
  ? " AND p.project_name = '" + project.replace(/'/g, "''") + "'"
4356
4810
  : '';
4811
+ // Split posts_made into threads_made (we authored the thread itself) vs
4812
+ // comments_made (we engaged on someone else's thread). Matches the
4813
+ // /api/activity classifier: thread iff thread_url = our_url AND
4814
+ // (thread_author IS NULL OR thread_author = our_account).
4815
+ const threadClause =
4816
+ "p.thread_url = p.our_url AND (p.thread_author IS NULL OR p.thread_author = p.our_account)";
4357
4817
  const q =
4358
4818
  "SELECT json_agg(row_to_json(r)) FROM (" +
4359
4819
  "SELECT to_char((p.posted_at AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') AS day, " +
4360
- "COUNT(*)::bigint AS posts_made " +
4820
+ "COUNT(*)::bigint AS posts_made, " +
4821
+ "SUM(CASE WHEN " + threadClause + " THEN 1 ELSE 0 END)::bigint AS threads_made, " +
4822
+ "SUM(CASE WHEN " + threadClause + " THEN 0 ELSE 1 END)::bigint AS comments_made " +
4361
4823
  "FROM posts p " +
4362
4824
  "WHERE p.posted_at IS NOT NULL " +
4363
4825
  "AND p.posted_at >= CURRENT_DATE - INTERVAL '" + days + " days' " +
@@ -4384,7 +4846,7 @@ async function handleApi(req, res) {
4384
4846
  const days = Math.max(1, Math.min(365, parseInt(url.searchParams.get('days') || '30', 10) || 30));
4385
4847
  const rawProject = (url.searchParams.get('project') || '').trim();
4386
4848
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4387
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4849
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4388
4850
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4389
4851
  const cacheKey = days + '|' + project;
4390
4852
  const cached = bookingsPerDayCache.get(cacheKey);
@@ -4424,7 +4886,7 @@ async function handleApi(req, res) {
4424
4886
  const days = Math.max(1, Math.min(90, parseInt(url.searchParams.get('days') || '30', 10) || 30));
4425
4887
  const rawProject = (url.searchParams.get('project') || '').trim();
4426
4888
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4427
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4889
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4428
4890
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4429
4891
  const cacheKey = days + '|' + project;
4430
4892
  const cached = funnelPerDayCache.get(cacheKey);
@@ -4506,7 +4968,8 @@ async function handleApi(req, res) {
4506
4968
  const sumCols =
4507
4969
  "COALESCE(SUM(sc.per_row_cost), 0)::numeric(12,4) AS total_cost_usd, " +
4508
4970
  "COALESCE(SUM(sc.per_row_cost_orchestrator), 0)::numeric(12,4) AS total_cost_usd_orchestrator, " +
4509
- "COALESCE(SUM(sc.per_row_cost_estimated), 0)::numeric(12,4) AS total_cost_usd_estimated";
4971
+ "COALESCE(SUM(sc.per_row_cost_estimated), 0)::numeric(12,4) AS total_cost_usd_estimated, " +
4972
+ "COALESCE(SUM(sc.per_row_cost_subagent), 0)::numeric(12,4) AS total_cost_usd_subagent";
4510
4973
  if (includeThread) {
4511
4974
  rowQueries.push(
4512
4975
  "SELECT 'thread' AS type, COUNT(*)::int AS count, " + sumCols + " " +
@@ -4545,15 +5008,46 @@ async function handleApi(req, res) {
4545
5008
  "WITH src AS (" + parts.join(' UNION ALL ') + "), " +
4546
5009
  "session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
4547
5010
  "session_cost AS (SELECT cs.session_id, " +
4548
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
5011
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
4549
5012
  "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_orchestrator, " +
4550
- "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated " +
5013
+ "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated, " +
5014
+ "(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_subagent " +
4551
5015
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id) " +
4552
5016
  "SELECT json_agg(row_to_json(r)) FROM (" + rowQueries.join(' UNION ALL ') + ") r";
5017
+ // Per-phase (script) cost rollup over the same window. Groups every
5018
+ // claude_sessions row in the window by its `script` column (e.g.
5019
+ // run-twitter-cycle-scan, post_reddit, seo_generate_page) and surfaces
5020
+ // total + orchestrator + subagent + estimate lanes. Independent of the
5021
+ // activity-type rollup above: a single post_reddit session might produce
5022
+ // 0 or 1 thread row, but its cost still shows up in the per-phase view.
5023
+ // SDK-only per-phase rollup. total_cost_usd = SUM(orchestrator_cost_usd)
5024
+ // — no transcript estimate, no subagent fold-in. Phases with 0% SDK
5025
+ // coverage (wrapper doesn't capture --orchestrator-cost-usd) show
5026
+ // total $0 but a non-zero sessions_missing_sdk count, which is the
5027
+ // signal to investigate. Also surfaces the estimate and subagent
5028
+ // columns as diagnostic-only fields.
5029
+ const phaseQ =
5030
+ "SELECT script AS phase, " +
5031
+ "COUNT(*)::int AS sessions, " +
5032
+ "COUNT(orchestrator_cost_usd)::int AS sessions_with_sdk, " +
5033
+ "(COUNT(*) - COUNT(orchestrator_cost_usd))::int AS sessions_missing_sdk, " +
5034
+ "COALESCE(SUM(orchestrator_cost_usd), 0)::numeric(12,4) AS total_cost_usd, " +
5035
+ "COALESCE(SUM(orchestrator_cost_usd), 0)::numeric(12,4) AS total_cost_usd_orchestrator, " +
5036
+ "COALESCE(SUM(total_cost_usd), 0)::numeric(12,4) AS total_cost_usd_estimated, " +
5037
+ "COALESCE(SUM(subagent_cost_usd), 0)::numeric(12,4) AS total_cost_usd_subagent " +
5038
+ "FROM claude_sessions " +
5039
+ "WHERE started_at >= NOW() - " + win + " " +
5040
+ "GROUP BY script " +
5041
+ "HAVING COUNT(*) > 0 " +
5042
+ "ORDER BY total_cost_usd DESC, sessions DESC " +
5043
+ "LIMIT 50";
4553
5044
  return (async () => {
4554
5045
  const dbRows = await pq(q);
4555
5046
  const value = (dbRows && dbRows.length && dbRows[0].json_agg) ? dbRows[0].json_agg : [];
4556
- return json(res, { windowHours, platform: plat || 'all', rows: value });
5047
+ let phases = [];
5048
+ try { phases = await pq(phaseQ) || []; }
5049
+ catch (e) { console.error('[cost/stats] phase query failed:', e && e.message || e); }
5050
+ return json(res, { windowHours, platform: plat || 'all', rows: value, phases });
4557
5051
  })().catch(e => json(res, { error: e.message }, 500));
4558
5052
  }
4559
5053
 
@@ -4583,7 +5077,7 @@ async function handleApi(req, res) {
4583
5077
  const days = Math.max(1, Math.min(365, parseInt(url.searchParams.get('days') || '30', 10) || 30));
4584
5078
  const rawProject = (url.searchParams.get('project') || '').trim();
4585
5079
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4586
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
5080
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4587
5081
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4588
5082
  const ALLOWED_COST_PLATFORMS = new Set(['reddit', 'twitter', 'linkedin', 'moltbook', 'github', 'seo', 'email']);
4589
5083
  let rawPlat = String(url.searchParams.get('platform') || '').toLowerCase().trim();
@@ -4674,7 +5168,7 @@ async function handleApi(req, res) {
4674
5168
  "WITH src AS (" + parts.join(' UNION ALL ') + "), " +
4675
5169
  "session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
4676
5170
  "session_cost AS (SELECT cs.session_id, " +
4677
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost " +
5171
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost " +
4678
5172
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id), " +
4679
5173
  "in_window AS (" + inWindow.join(' UNION ALL ') + ") " +
4680
5174
  "SELECT json_agg(row_to_json(r)) FROM (" +
@@ -5181,10 +5675,47 @@ async function handleApi(req, res) {
5181
5675
  const pc = auth.projectClause(req.user, 'project_name', url.searchParams.get('project'));
5182
5676
  if (!pc.ok) return json(res, { posts: [], window: windowKey, platform: platformFilter || 'all', kind: kindFilter });
5183
5677
  if (pc.clause) whereParts.push(pc.clause.replace(/^\s*AND\s+/, ''));
5678
+ // 2026-05-18 dashboard parity for Reddit DM-rail follow-ups.
5679
+ // Reply-to-reply rows (we replied back to someone who replied to our
5680
+ // top-level comment) live in the `replies` table, not `posts`, so they
5681
+ // were invisible on the Top tab even though they have real engagement
5682
+ // (some routinely break 1000 views). LinkedIn migrated the analogous
5683
+ // data into `posts` on 2026-05-11, but the (platform, thread_url) dedup
5684
+ // on /api/v1/posts blocks the same migration for Reddit (most reply-
5685
+ // to-replies share thread_url with our top-level post in the same
5686
+ // thread). UNION-into-/api/top sidesteps that entirely: the `replies`
5687
+ // table stays the source of truth, and the Top tab surfaces both
5688
+ // surfaces. The activity feed (line ~3919) already filters
5689
+ // r.status='replied' so reply-to-replies appear ONCE there.
5690
+ //
5691
+ // Build the same WHERE clause for the replies branch, but referencing
5692
+ // `r2.replied_at` for the time window and `parent.thread_url` semantics
5693
+ // for the kind filter. Replies are by definition never threads, so
5694
+ // kind='threads' must exclude the whole branch.
5695
+ const repliesPc = auth.projectClause(req.user, 'parent.project_name', url.searchParams.get('project'));
5696
+ const repliesWhere = [
5697
+ "r2.platform = 'reddit'", // only Reddit has the gap today
5698
+ "r2.status = 'replied'",
5699
+ "r2.our_reply_id IS NOT NULL",
5700
+ "r2.our_reply_url IS NOT NULL",
5701
+ "r2.our_reply_content IS NOT NULL AND LENGTH(r2.our_reply_content) >= 30",
5702
+ ];
5703
+ if (windowHours != null) {
5704
+ repliesWhere.push("r2.replied_at >= NOW() - INTERVAL '" + windowHours + " hours'");
5705
+ }
5706
+ if (platformFilter && platformFilter !== 'reddit') {
5707
+ // Caller filtered to a non-Reddit platform; replies branch yields nothing.
5708
+ repliesWhere.push("FALSE");
5709
+ }
5710
+ // kind: 'threads' excludes replies entirely; 'comments' and 'all' include.
5711
+ if (kindFilter === 'threads') {
5712
+ repliesWhere.push("FALSE");
5713
+ }
5714
+ if (repliesPc.clause) repliesWhere.push(repliesPc.clause.replace(/^\s*AND\s+/, ''));
5184
5715
  // Moltbook and GitHub have no views metric; return NULL for those so the UI can
5185
5716
  // render a dash instead of a misleading 0. Score still uses COALESCE so they
5186
5717
  // rank alongside other platforms based on upvotes + comments only.
5187
- const q = "SELECT json_agg(row_to_json(r)) FROM (" +
5718
+ const postsBranch =
5188
5719
  "SELECT posts.id, posts.platform, " +
5189
5720
  // Upvotes are reported NET on Reddit/Moltbook (both auto-apply a +1 OP
5190
5721
  // self-upvote on every post). Strip it per row, clamped at 0 so
@@ -5216,7 +5747,8 @@ async function handleApi(req, res) {
5216
5747
  "COALESCE(pl.bot_clicks, 0)::int AS link_bot_clicks, " +
5217
5748
  "COALESCE(pl.backfill_real, 0)::int AS link_backfill_real, " +
5218
5749
  "COALESCE(pl.link_count, 0)::int AS link_count, " +
5219
- "pl.first_code AS link_code " +
5750
+ "pl.first_code AS link_code, " +
5751
+ "'post'::text AS row_kind " +
5220
5752
  "FROM posts LEFT JOIN campaigns c ON c.id = posts.campaign_id " +
5221
5753
  // pl rollup: legacy `total_clicks` reads the post_links.clicks integer
5222
5754
  // (humans-only after 2026-05-07; pre-existing rows include bots).
@@ -5245,6 +5777,50 @@ async function handleApi(req, res) {
5245
5777
  ") pl ON pl.post_id = posts.id " +
5246
5778
  "WHERE " + whereParts.join(' AND ') + " " +
5247
5779
  "ORDER BY upvotes DESC NULLS LAST, comments_count DESC NULLS LAST, views DESC NULLS LAST " +
5780
+ "LIMIT " + limit;
5781
+ // Replies branch: shape-compatible SELECT against `replies` JOIN `posts` (parent)
5782
+ // for thread context. ID is negated to guarantee uniqueness within the UNION
5783
+ // (posts.id and replies.id are independent sequences and would otherwise
5784
+ // collide for sort/key purposes). The FE only uses `id` as a React key, so
5785
+ // negative integers are fine.
5786
+ const repliesBranch =
5787
+ "SELECT (-r2.id)::int AS id, r2.platform, " +
5788
+ // Reddit-only branch today; strip the OP self-upvote like the posts branch.
5789
+ "GREATEST(0, COALESCE(r2.upvotes, 0) - 1)::int AS upvotes, " +
5790
+ "COALESCE(r2.comments_count, 0)::int AS comments_count, " +
5791
+ "COALESCE(r2.views, 0)::int AS views, " +
5792
+ // Same score formula. Views (Reddit only) contribute /100.
5793
+ "(COALESCE(r2.comments_count,0) * 5 " +
5794
+ "+ GREATEST(0, COALESCE(r2.upvotes,0) - 1) * 5 " +
5795
+ "+ COALESCE(r2.views,0) / 100)::int AS score, " +
5796
+ "FALSE AS is_thread, " +
5797
+ "r2.replied_at AS posted_at, " +
5798
+ "r2.engagement_updated_at, " +
5799
+ "r2.our_reply_content AS our_content, " +
5800
+ "r2.our_reply_url AS our_url, " +
5801
+ "parent.thread_url, parent.thread_title, " +
5802
+ "LEFT(COALESCE(parent.thread_content, ''), 400) AS thread_content, " +
5803
+ "parent.our_account, parent.project_name, " +
5804
+ "r2.engagement_style, r2.is_recommendation, " +
5805
+ "cr.name AS campaign_name, " +
5806
+ // No link tracking on reply-to-replies yet (we don't usually drop a CTA
5807
+ // there). All link counters return 0 so they sort to the bottom of
5808
+ // any link-clicks ordering.
5809
+ "0::int AS link_clicks, 0::int AS link_real_clicks, " +
5810
+ "0::int AS link_bot_clicks, 0::int AS link_backfill_real, " +
5811
+ "0::int AS link_count, NULL::text AS link_code, " +
5812
+ "'reply'::text AS row_kind " +
5813
+ "FROM replies r2 " +
5814
+ "LEFT JOIN posts parent ON parent.id = r2.post_id " +
5815
+ "LEFT JOIN campaigns cr ON cr.id = r2.campaign_id " +
5816
+ "WHERE " + repliesWhere.join(' AND ') + " " +
5817
+ "ORDER BY r2.upvotes DESC NULLS LAST, r2.comments_count DESC NULLS LAST, r2.views DESC NULLS LAST " +
5818
+ "LIMIT " + limit;
5819
+ const q = "SELECT json_agg(row_to_json(r)) FROM (" +
5820
+ "SELECT * FROM (" + postsBranch + ") posts_branch " +
5821
+ "UNION ALL " +
5822
+ "SELECT * FROM (" + repliesBranch + ") replies_branch " +
5823
+ "ORDER BY upvotes DESC NULLS LAST, comments_count DESC NULLS LAST, views DESC NULLS LAST " +
5248
5824
  "LIMIT " + limit +
5249
5825
  ") r";
5250
5826
  return (async () => {
@@ -5254,6 +5830,134 @@ async function handleApi(req, res) {
5254
5830
  })().catch(e => json(res, { error: e.message }, 500));
5255
5831
  }
5256
5832
 
5833
+ // GET /api/top/destinations - post links rolled up by target URL.
5834
+ // One row per unique destination URL (e.g. https://s4l.ai vs
5835
+ // https://s4l.ai/ghostwriting vs https://s4l.ai/t/<slug>), with click totals
5836
+ // aggregated across every short code that pointed at that URL. Used by the
5837
+ // "Links" subtab in the Top tab to answer "where are my posts sending
5838
+ // traffic and how many clicks does each destination get?"
5839
+ if (p === '/api/top/destinations' && req.method === 'GET') {
5840
+ const url = new URL(req.url, 'http://localhost');
5841
+ const WINDOW_HOURS = { '24h': 24, '7d': 24*7, '14d': 24*14, '30d': 24*30, '90d': 24*90, 'all': null };
5842
+ const rawWindow = String(url.searchParams.get('window') || '7d').toLowerCase();
5843
+ const windowKey = Object.prototype.hasOwnProperty.call(WINDOW_HOURS, rawWindow) ? rawWindow : '7d';
5844
+ const windowHours = WINDOW_HOURS[windowKey];
5845
+ const rawPlatform = String(url.searchParams.get('platform') || '').toLowerCase().trim();
5846
+ const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github']);
5847
+ const platformFilter = ALLOWED_PLATFORMS.has(rawPlatform) ? rawPlatform : '';
5848
+ const pc = auth.projectClause(req.user, 'pl.project_name', url.searchParams.get('project'));
5849
+ if (!pc.ok) return json(res, { destinations: [], window: windowKey, platform: 'all' });
5850
+ const limit = Math.max(50, Math.min(500, parseInt(url.searchParams.get('limit') || '200', 10) || 200));
5851
+ const whereParts = [];
5852
+ if (windowHours != null) {
5853
+ whereParts.push("pl.minted_at >= NOW() - INTERVAL '" + windowHours + " hours'");
5854
+ }
5855
+ if (platformFilter) {
5856
+ whereParts.push("LOWER(pl.platform) = '" + platformFilter + "'");
5857
+ }
5858
+ if (pc.clause) whereParts.push(pc.clause.replace(/^\s*AND\s+/, ''));
5859
+ const whereSql = whereParts.length ? ('WHERE ' + whereParts.join(' AND ')) : '';
5860
+ // Grouping key is the URL with all query params + trailing slash stripped.
5861
+ // UTM params (utm_source / utm_medium / utm_campaign / utm_term /
5862
+ // utm_content) are baked into every target_url at mint time so each
5863
+ // post gets its own uniquely-tracked URL even when posting at the same
5864
+ // destination; without stripping, the same homepage would split into
5865
+ // 50 rows. Path is preserved (so /ghostwriting stays distinct from /).
5866
+ // Project + platform stay in GROUP BY so a multi-project repo (one
5867
+ // website hosting pages for several projects) keeps them on separate
5868
+ // rows.
5869
+ const q = "SELECT json_agg(row_to_json(r)) FROM (" +
5870
+ "SELECT " +
5871
+ "REGEXP_REPLACE(REGEXP_REPLACE(pl.target_url, '\\?.*$', ''), '/$', '') AS target_url, " +
5872
+ "pl.project_name, pl.platform, " +
5873
+ "COUNT(DISTINCT pl.post_id)::int AS posts, " +
5874
+ "COUNT(*)::int AS codes, " +
5875
+ "COALESCE(SUM(pl.clicks), 0)::int AS legacy_clicks, " +
5876
+ "COALESCE(SUM(plc.real_clicks), 0)::int AS real_clicks, " +
5877
+ "COALESCE(SUM(plc.bot_clicks), 0)::int AS bot_clicks, " +
5878
+ "COALESCE(SUM(pl.real_clicks), 0)::int AS backfill_real, " +
5879
+ "MIN(pl.minted_at) AS first_minted_at, " +
5880
+ "MAX(pl.last_click_at) AS last_click_at " +
5881
+ "FROM post_links pl " +
5882
+ "LEFT JOIN (" +
5883
+ "SELECT code, " +
5884
+ " COUNT(*) FILTER (WHERE is_bot = false)::int AS real_clicks, " +
5885
+ " COUNT(*) FILTER (WHERE is_bot = true)::int AS bot_clicks " +
5886
+ "FROM post_link_clicks GROUP BY code" +
5887
+ ") plc ON plc.code = pl.code " +
5888
+ whereSql + " " +
5889
+ "GROUP BY 1, pl.project_name, pl.platform " +
5890
+ "ORDER BY real_clicks DESC NULLS LAST, legacy_clicks DESC NULLS LAST, codes DESC " +
5891
+ "LIMIT " + limit +
5892
+ ") r";
5893
+ return (async () => {
5894
+ const rows = await pq(q);
5895
+ const destinations = (rows && rows.length && rows[0].json_agg) ? rows[0].json_agg : [];
5896
+ // Server-side classification of each destination URL into a kind bucket
5897
+ // and (when applicable) the audience-page angle. Reads config.json
5898
+ // once per request to look up each project's website host + audience
5899
+ // pages list; classification is plain hostname / path matching. Done
5900
+ // here so every consumer (UI, future CSV exports) sees the same
5901
+ // canonical label without having to re-implement classify logic.
5902
+ let cfg = null;
5903
+ try {
5904
+ cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
5905
+ } catch (_e) { cfg = { projects: [] }; }
5906
+ const projIdx = {};
5907
+ for (const p of (cfg && cfg.projects) || []) {
5908
+ if (!p || !p.name) continue;
5909
+ const host = String(p.website || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase();
5910
+ const audience = ((p.landing_pages || {}).audience_pages) || [];
5911
+ const apIdx = [];
5912
+ for (const a of audience) {
5913
+ try {
5914
+ const u = new URL(a.url);
5915
+ apIdx.push({
5916
+ angle: a.angle,
5917
+ host: (u.hostname || '').toLowerCase().replace(/^www\./, ''),
5918
+ path: (u.pathname || '/').replace(/\/+$/, '') || '/',
5919
+ });
5920
+ } catch (_e) {}
5921
+ }
5922
+ projIdx[p.name] = { website_host: host, audience_pages: apIdx };
5923
+ }
5924
+ const classify = (targetUrl, projectName) => {
5925
+ if (!targetUrl) return { kind: 'other', audience_page_angle: null };
5926
+ let host = '', pathName = '/';
5927
+ try {
5928
+ const u = new URL(targetUrl);
5929
+ host = (u.hostname || '').toLowerCase().replace(/^www\./, '');
5930
+ pathName = (u.pathname || '/').replace(/\/+$/, '') || '/';
5931
+ } catch (_e) { return { kind: 'other', audience_page_angle: null }; }
5932
+ if (/(^|\.)cal\.com$/.test(host) || /(^|\.)calendly\.com$/.test(host)) return { kind: 'booking', audience_page_angle: null };
5933
+ if (host === 'github.com') return { kind: 'github', audience_page_angle: null };
5934
+ const entry = projectName ? projIdx[projectName] : null;
5935
+ // Audience-page exact host+path match wins over generic SUBPAGE.
5936
+ if (entry) {
5937
+ for (const ap of entry.audience_pages || []) {
5938
+ if (ap.host === host && ap.path === pathName) {
5939
+ return { kind: 'audience_page', audience_page_angle: ap.angle || null };
5940
+ }
5941
+ }
5942
+ if (entry.website_host && host === entry.website_host) {
5943
+ if (pathName === '/' || pathName === '') return { kind: 'home', audience_page_angle: null };
5944
+ if (/^\/t\//.test(pathName)) return { kind: 'seo', audience_page_angle: null };
5945
+ return { kind: 'subpage', audience_page_angle: null };
5946
+ }
5947
+ }
5948
+ if (pathName === '/' || pathName === '') return { kind: 'other', audience_page_angle: null };
5949
+ if (/^\/t\//.test(pathName)) return { kind: 'seo', audience_page_angle: null };
5950
+ return { kind: 'external', audience_page_angle: null };
5951
+ };
5952
+ for (const d of destinations) {
5953
+ const c = classify(d.target_url, d.project_name);
5954
+ d.kind = c.kind;
5955
+ d.audience_page_angle = c.audience_page_angle;
5956
+ }
5957
+ return json(res, { destinations, window: windowKey, platform: platformFilter || 'all' });
5958
+ })().catch(e => json(res, { error: e.message }, 500));
5959
+ }
5960
+
5257
5961
  // GET /api/top/links - post short links ranked by click count.
5258
5962
  // Queries post_links joined with posts so the content snippet is available.
5259
5963
  // Returns links with >= 1 click, ordered by clicks desc. Used by the "Links"
@@ -5319,36 +6023,44 @@ async function handleApi(req, res) {
5319
6023
  // slow (~15-30s), so we cache for 10 min and dedupe concurrent callers.
5320
6024
  // A launchd timer (com.m13v.social-precompute-stats) also writes fresh
5321
6025
  // snapshots to skill/cache/funnel_stats_<N>d.json so cold starts are instant.
6026
+ //
6027
+ // Platform filter handling:
6028
+ // !platform: read the all-platform snapshot from disk (~instant).
6029
+ // platform set: read the snapshot for funnel cells (pageviews / signups /
6030
+ // bookings / amplitude are platform-independent since they measure on-
6031
+ // site behavior, not which social platform drove the visitor) AND spawn
6032
+ // python with --posts-only --platform=X to recompute ONLY the per-project
6033
+ // posts.* engagement counters (~1.5s instead of ~30s+ because the
6034
+ // PostHog/Amplitude/bookings batch is skipped). Merge the two into one
6035
+ // payload so the dashboard sees platform-scoped engagement on the same
6036
+ // row as the all-source funnel cells.
5322
6037
  if (p === '/api/funnel/stats' && req.method === 'GET') {
5323
6038
  const url = new URL(req.url, 'http://localhost');
5324
6039
  const days = Math.max(1, Math.min(90, parseInt(url.searchParams.get('days') || '1', 10) || 1));
5325
- const entry = funnelStatsCache.get(days);
6040
+ const rawPlatform = (url.searchParams.get('platform') || '').trim().toLowerCase();
6041
+ const platform = (rawPlatform === '' || rawPlatform === 'all') ? '' :
6042
+ (rawPlatform === 'x' ? 'twitter' : rawPlatform);
6043
+ const platformOk = platform === '' || /^[a-z0-9_]{1,32}$/.test(platform);
6044
+ if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
6045
+ const cacheKey = days + '|' + platform;
6046
+ const entry = funnelStatsCache.get(cacheKey);
5326
6047
  const TTL_MS = 600000;
5327
6048
  if (entry && entry.value && Date.now() - entry.at < TTL_MS) {
5328
- return json(res, scopeFunnelStatsPayload({ days, ...entry.value, cachedAt: entry.at }, req.user));
5329
- }
5330
- const snap = await readSnapshotCached(`funnel_stats_${days}d.json`);
5331
- if (snap && snap.value && !snap.value.error) {
5332
- // Warm the in-memory cache so subsequent hits skip the disk read too.
5333
- funnelStatsCache.set(days, { at: snap.at, value: snap.value });
5334
- return json(res, scopeFunnelStatsPayload({ ...snap.value, cachedAt: snap.at }, req.user));
6049
+ return json(res, scopeFunnelStatsPayload({ days, platform: platform || 'all', ...entry.value, cachedAt: entry.at }, req.user));
5335
6050
  }
5336
6051
  if (entry && entry.pending) {
5337
- entry.pending.then(val => json(res, scopeFunnelStatsPayload({ days, ...val, cachedAt: Date.now() }, req.user)))
6052
+ entry.pending.then(val => json(res, scopeFunnelStatsPayload({ days, platform: platform || 'all', ...val, cachedAt: Date.now() }, req.user)))
5338
6053
  .catch(err => json(res, { error: String(err && err.message || err) }, 500));
5339
6054
  return;
5340
6055
  }
5341
- // Cloud Run has no python runtime and no PostHog creds; only the
5342
- // operator's local server can run the live pipeline. Return whatever
5343
- // we've got (empty snapshot if nothing) rather than hanging.
5344
6056
  if (auth.CLIENT_MODE) {
5345
- return json(res, { days, error: 'snapshot_missing', cachedAt: null }, 503);
6057
+ return json(res, { days, platform: platform || 'all', error: 'snapshot_missing', cachedAt: null }, 503);
5346
6058
  }
6059
+
5347
6060
  const scriptPath = path.join(DEST, 'scripts', 'project_stats_json.py');
5348
- const pending = new Promise((resolve, reject) => {
5349
- const child = spawn('python3', [scriptPath, '--days', String(days)], {
5350
- env: process.env, cwd: DEST,
5351
- });
6061
+ const runPython = (extraArgs) => new Promise((resolve, reject) => {
6062
+ const args = [scriptPath, '--days', String(days), ...extraArgs];
6063
+ const child = spawn('python3', args, { env: process.env, cwd: DEST });
5352
6064
  let out = '', err = '';
5353
6065
  child.stdout.on('data', d => out += d);
5354
6066
  child.stderr.on('data', d => err += d);
@@ -5358,12 +6070,69 @@ async function handleApi(req, res) {
5358
6070
  try { resolve(JSON.parse(out)); } catch (e) { reject(e); }
5359
6071
  });
5360
6072
  });
5361
- funnelStatsCache.set(days, { at: Date.now(), pending });
6073
+
6074
+ let pending;
6075
+ if (platform) {
6076
+ // Fast platform-filtered path: snapshot (funnel cells) + posts-only overlay.
6077
+ // Falls back to the slow full pipeline if no all-platform snapshot exists.
6078
+ pending = (async () => {
6079
+ // Use a 24h freshness window for the overlay base: the snapshot only
6080
+ // contributes the funnel cells (pageviews / signups / bookings /
6081
+ // amplitude / SEO pages), all of which move on a daily-ish timescale.
6082
+ // The posts.* counters (which DO move every few minutes) are
6083
+ // recomputed live via --posts-only, so a stale funnel-cell base is
6084
+ // safe and avoids falling back to the 30s+ full pipeline whenever
6085
+ // the precompute job lags >15min.
6086
+ const STALE_OK = 24 * 60 * 60 * 1000;
6087
+ const snap = await readSnapshotCached(`funnel_stats_${days}d.json`, STALE_OK);
6088
+ if (!snap || !snap.value || snap.value.error || !Array.isArray(snap.value.projects)) {
6089
+ return runPython(['--platform', platform]);
6090
+ }
6091
+ const overlay = await runPython(['--platform', platform, '--posts-only']);
6092
+ const overlayByName = new Map();
6093
+ for (const op of overlay.projects || []) {
6094
+ if (op && op.name) overlayByName.set(op.name, op);
6095
+ }
6096
+ const mergedProjects = snap.value.projects.map(snapP => {
6097
+ if (!snapP || !snapP.name) return snapP;
6098
+ const op = overlayByName.get(snapP.name);
6099
+ if (!op || op.error || !op.posts) return snapP;
6100
+ // Replace ONLY the posts.* counters from the overlay; everything
6101
+ // else (funnel, posthog, bookings, seo, platforms, analytics flags)
6102
+ // stays at the snapshot's all-platform value because those metrics
6103
+ // don't move with the social-platform filter.
6104
+ return { ...snapP, posts: { ...(snapP.posts || {}), ...op.posts } };
6105
+ });
6106
+ return {
6107
+ generated_at: overlay.generated_at || snap.value.generated_at,
6108
+ days,
6109
+ platform,
6110
+ posts_only_overlay: true,
6111
+ projects: mergedProjects,
6112
+ overall: snap.value.overall || null,
6113
+ };
6114
+ })();
6115
+ } else {
6116
+ // Prefer a fresh snapshot. If none, accept a stale-but-recent one
6117
+ // (<24h) to avoid blocking the user behind a 30s+ live python run
6118
+ // every time the precompute job lags. The 5-min precompute timer
6119
+ // cycles through all five windows (1d/7d/14d/30d/90d) so any one
6120
+ // can be 20-30min old at random.
6121
+ const STALE_OK = 24 * 60 * 60 * 1000;
6122
+ const snap = await readSnapshotCached(`funnel_stats_${days}d.json`, STALE_OK);
6123
+ if (snap && snap.value && !snap.value.error) {
6124
+ funnelStatsCache.set(cacheKey, { at: snap.at, value: snap.value });
6125
+ return json(res, scopeFunnelStatsPayload({ ...snap.value, cachedAt: snap.at }, req.user));
6126
+ }
6127
+ pending = runPython([]);
6128
+ }
6129
+
6130
+ funnelStatsCache.set(cacheKey, { at: Date.now(), pending });
5362
6131
  pending.then(val => {
5363
- funnelStatsCache.set(days, { at: Date.now(), value: val });
5364
- json(res, scopeFunnelStatsPayload({ days, ...val, cachedAt: Date.now() }, req.user));
6132
+ funnelStatsCache.set(cacheKey, { at: Date.now(), value: val });
6133
+ json(res, scopeFunnelStatsPayload({ days, platform: platform || 'all', ...val, cachedAt: Date.now() }, req.user));
5365
6134
  }).catch(err => {
5366
- funnelStatsCache.delete(days);
6135
+ funnelStatsCache.delete(cacheKey);
5367
6136
  json(res, { error: String(err && err.message || err) }, 500);
5368
6137
  });
5369
6138
  return;
@@ -5486,11 +6255,23 @@ async function handleApi(req, res) {
5486
6255
  "AND query IS NOT NULL AND length(trim(query)) > 0 " +
5487
6256
  "), " +
5488
6257
  "cand AS ( " +
5489
- "SELECT 'twitter' AS platform, c.search_topic AS query, " +
6258
+ // Twitter: c.search_topic is the SEED concept (e.g. "vibe coding") while
6259
+ // twitter_search_attempts.query is the literal X advanced-search string
6260
+ // (e.g. '("vibe coded" OR ...) min_faves:30 since:... -filter:replies').
6261
+ // The two never line up textually, so we associate each candidate to
6262
+ // its parent attempt via (batch_id, project_name) and project the
6263
+ // attempt's `query` as the join key. When one project ran multiple
6264
+ // queries in the same batch (~1.5% of cases), this attributes each
6265
+ // candidate to all of them — known minor over-attribution, acceptable
6266
+ // until we add a per-attempt seed column.
6267
+ "SELECT 'twitter' AS platform, a.query, " +
5490
6268
  "COALESCE(c.matched_project, '(none)') AS project_name, c.post_id " +
5491
6269
  "FROM twitter_candidates c " +
6270
+ "JOIN twitter_search_attempts a " +
6271
+ "ON a.batch_id = c.batch_id " +
6272
+ "AND COALESCE(a.project_name, '(none)') = COALESCE(c.matched_project, '(none)') " +
5492
6273
  "WHERE c.discovered_at >= NOW() - INTERVAL '" + windowHours + " hours' " +
5493
- "AND c.search_topic IS NOT NULL " +
6274
+ "AND c.batch_id IS NOT NULL " +
5494
6275
  "UNION ALL " +
5495
6276
  "SELECT 'linkedin', c.search_query, COALESCE(c.matched_project, '(none)'), c.post_id " +
5496
6277
  "FROM linkedin_candidates c " +
@@ -5680,7 +6461,7 @@ async function handleApi(req, res) {
5680
6461
  // suppresses the column. SDK and estimate lanes are surfaced separately
5681
6462
  // so the dashboard tooltip can show both, same UX as cost-stats.
5682
6463
  let costByProject = {};
5683
- let grandCost = 0, grandCostOrch = 0, grandCostEst = 0;
6464
+ let grandCost = 0, grandCostOrch = 0, grandCostEst = 0, grandCostSub = 0;
5684
6465
  if (req.user && req.user.admin) {
5685
6466
  const costSrcParts = [
5686
6467
  "SELECT claude_session_id FROM posts WHERE claude_session_id IS NOT NULL AND posted_at IS NOT NULL",
@@ -5694,24 +6475,24 @@ async function handleApi(req, res) {
5694
6475
  const costWin = "INTERVAL '" + hours + " hours'";
5695
6476
  const costAttributed = [
5696
6477
  "SELECT COALESCE(posts.project_name, '(none)') AS project, " +
5697
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6478
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5698
6479
  "FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id " +
5699
6480
  "WHERE posts.posted_at >= NOW() - " + costWin + " " +
5700
6481
  "AND posts.our_content <> '(mention - no original post)'",
5701
6482
  "SELECT COALESCE(replies.project_name, '(none)') AS project, " +
5702
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6483
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5703
6484
  "FROM replies LEFT JOIN session_cost sc ON sc.session_id = replies.claude_session_id " +
5704
6485
  "WHERE replies.status='replied' AND replies.replied_at >= NOW() - " + costWin,
5705
6486
  "SELECT COALESCE(dms.target_project, '(none)') AS project, " +
5706
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6487
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5707
6488
  "FROM dms LEFT JOIN session_cost sc ON sc.session_id = dms.claude_session_id " +
5708
6489
  "WHERE dms.status='sent' AND dms.sent_at >= NOW() - " + costWin,
5709
6490
  "SELECT COALESCE(seo_keywords.product, '(none)') AS project, " +
5710
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6491
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5711
6492
  "FROM seo_keywords LEFT JOIN session_cost sc ON sc.session_id = seo_keywords.claude_session_id " +
5712
6493
  "WHERE seo_keywords.completed_at >= NOW() - " + costWin + " AND seo_keywords.page_url IS NOT NULL",
5713
6494
  "SELECT COALESCE(gsc_queries.product, '(none)') AS project, " +
5714
- "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
6495
+ "sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
5715
6496
  "FROM gsc_queries LEFT JOIN session_cost sc ON sc.session_id = gsc_queries.claude_session_id " +
5716
6497
  "WHERE gsc_queries.completed_at >= NOW() - " + costWin + " AND gsc_queries.page_url IS NOT NULL",
5717
6498
  ];
@@ -5719,15 +6500,17 @@ async function handleApi(req, res) {
5719
6500
  "WITH src AS (" + costSrcParts.join(' UNION ALL ') + "), " +
5720
6501
  "session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
5721
6502
  "session_cost AS (SELECT cs.session_id, " +
5722
- "(COALESCE(cs.orchestrator_cost_usd, cs.total_cost_usd) / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
6503
+ "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
5723
6504
  "(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_orchestrator, " +
5724
- "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated " +
6505
+ "(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated, " +
6506
+ "(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_subagent " +
5725
6507
  "FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id), " +
5726
6508
  "attributed AS (" + costAttributed.join(' UNION ALL ') + ") " +
5727
6509
  "SELECT project, " +
5728
6510
  "COALESCE(SUM(per_row_cost), 0)::numeric(12,4) AS cost_usd, " +
5729
6511
  "COALESCE(SUM(per_row_cost_orchestrator), 0)::numeric(12,4) AS cost_usd_orchestrator, " +
5730
- "COALESCE(SUM(per_row_cost_estimated), 0)::numeric(12,4) AS cost_usd_estimated " +
6512
+ "COALESCE(SUM(per_row_cost_estimated), 0)::numeric(12,4) AS cost_usd_estimated, " +
6513
+ "COALESCE(SUM(per_row_cost_subagent), 0)::numeric(12,4) AS cost_usd_subagent " +
5731
6514
  "FROM attributed GROUP BY project";
5732
6515
  try {
5733
6516
  const costRows = await pq(costQ) || [];
@@ -5736,10 +6519,12 @@ async function handleApi(req, res) {
5736
6519
  const c = Number(r.cost_usd) || 0;
5737
6520
  const co = Number(r.cost_usd_orchestrator) || 0;
5738
6521
  const ce = Number(r.cost_usd_estimated) || 0;
5739
- costByProject[proj] = { cost_usd: c, cost_usd_orchestrator: co, cost_usd_estimated: ce };
6522
+ const cs = Number(r.cost_usd_subagent) || 0;
6523
+ costByProject[proj] = { cost_usd: c, cost_usd_orchestrator: co, cost_usd_estimated: ce, cost_usd_subagent: cs };
5740
6524
  grandCost += c;
5741
6525
  grandCostOrch += co;
5742
6526
  grandCostEst += ce;
6527
+ grandCostSub += cs;
5743
6528
  });
5744
6529
  } catch (e) {
5745
6530
  // Soft fail: log and continue without cost data. Don't block the
@@ -5753,6 +6538,7 @@ async function handleApi(req, res) {
5753
6538
  r.cost_usd = c ? c.cost_usd : 0;
5754
6539
  r.cost_usd_orchestrator = c ? c.cost_usd_orchestrator : 0;
5755
6540
  r.cost_usd_estimated = c ? c.cost_usd_estimated : 0;
6541
+ r.cost_usd_subagent = c ? c.cost_usd_subagent : 0;
5756
6542
  return r;
5757
6543
  };
5758
6544
  projects.forEach(attachCost);
@@ -5791,6 +6577,7 @@ async function handleApi(req, res) {
5791
6577
  grand_cost_usd: grandCost,
5792
6578
  grand_cost_usd_orchestrator: grandCostOrch,
5793
6579
  grand_cost_usd_estimated: grandCostEst,
6580
+ grand_cost_usd_subagent: grandCostSub,
5794
6581
  cost_available: !!(req.user && req.user.admin),
5795
6582
  can_edit_weight: !auth.CLIENT_MODE && !!(req.user && req.user.admin),
5796
6583
  projects,
@@ -6389,6 +7176,19 @@ const HTML = `<!DOCTYPE html>
6389
7176
  #top-pages-container .style-stats-table th,
6390
7177
  #top-pages-container .style-stats-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 10px 10px; }
6391
7178
  #top-pages-container .style-stats-table td[data-col-key="path"] { white-space: normal; overflow: visible; text-overflow: clip; word-break: break-all; }
7179
+
7180
+ #top-links-container .style-stats-table { table-layout: fixed; }
7181
+ #top-links-container .style-stats-table th,
7182
+ #top-links-container .style-stats-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 10px 10px; vertical-align: top; }
7183
+ #top-links-container .style-stats-table td[data-col-key="target_url"] { white-space: normal; overflow: visible; text-overflow: clip; word-break: break-all; }
7184
+ #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; }
7185
+ #top-links-container .dest-kind-home { background: rgba(16,185,129,0.18); color: #10b981; }
7186
+ #top-links-container .dest-kind-subpage { background: rgba(59,130,246,0.18); color: #3b82f6; }
7187
+ #top-links-container .dest-kind-seo { background: rgba(168,85,247,0.18); color: #a855f7; }
7188
+ #top-links-container .dest-kind-booking { background: rgba(245,158,11,0.18); color: #f59e0b; }
7189
+ #top-links-container .dest-kind-github { background: rgba(148,163,184,0.18); color: #94a3b8; }
7190
+ #top-links-container .dest-kind-external { background: rgba(239,68,68,0.18); color: #ef4444; }
7191
+ #top-links-container .dest-kind-other { background: rgba(148,163,184,0.18); color: #94a3b8; }
6392
7192
  /* DMs sub-tab */
6393
7193
  #top-dms-container .style-stats-table { table-layout: fixed; }
6394
7194
  #top-dms-container .style-stats-table th,
@@ -6630,6 +7430,34 @@ const HTML = `<!DOCTYPE html>
6630
7430
  .daily-metrics-tooltip .tt-row { display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; color: var(--text); }
6631
7431
  .daily-metrics-tooltip .tt-row .swatch { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
6632
7432
  .daily-metrics-tooltip .tt-row .val { margin-left: auto; }
7433
+ /* Trends tab — per-project breakdown. One row per project, each row has a
7434
+ left-aligned project label and the same column chart used in the main
7435
+ section (just shorter). Stack vertically, lazy-loaded on details open;
7436
+ re-rendered (no refetch) when the user toggles legend pills. */
7437
+ .per-project-breakdown { padding: 0 20px 14px; }
7438
+ .per-project-breakdown > summary { display: flex; align-items: center; gap: 8px; padding: 6px 0; cursor: pointer; font-size: 12px; color: var(--text-secondary); user-select: none; list-style: none; }
7439
+ .per-project-breakdown > summary::-webkit-details-marker { display: none; }
7440
+ .per-project-breakdown > summary .pp-caret { display: inline-block; width: 10px; transition: transform 0.1s; }
7441
+ .per-project-breakdown[open] > summary .pp-caret { transform: rotate(90deg); }
7442
+ .per-project-breakdown > summary .pp-status { margin-left: auto; color: var(--text-muted); font-size: 11px; }
7443
+ .per-project-rows { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
7444
+ .per-project-row { display: grid; grid-template-columns: 140px 1fr; align-items: stretch; gap: 8px; padding: 4px 0; border-top: 1px dashed var(--border); }
7445
+ .per-project-row:first-child { border-top: none; }
7446
+ .per-project-label { font-size: 12px; color: var(--text); font-weight: 500; padding: 6px 0 0 4px; word-break: break-word; }
7447
+ .per-project-chart { position: relative; min-height: 130px; }
7448
+ .per-project-chart svg { display: block; width: 100%; height: 130px; overflow: visible; }
7449
+ .per-project-chart .gridline { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 3; }
7450
+ .per-project-chart .axis-text { fill: var(--text-secondary); font-size: 9px; font-variant-numeric: tabular-nums; }
7451
+ .per-project-chart .series-bar { transition: opacity 0.1s; }
7452
+ .per-project-chart .series-bar:hover { opacity: 0.85; }
7453
+ .per-project-chart .hover-line { stroke: var(--text-muted); stroke-width: 1; stroke-dasharray: 3 3; opacity: 0; pointer-events: none; }
7454
+ .per-project-chart .pp-tooltip { position: absolute; pointer-events: none; background: var(--bg-panel, #fff); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font-size: 11px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); min-width: 160px; max-width: 240px; opacity: 0; transition: opacity 0.08s; z-index: 5; left: 0; top: 0; }
7455
+ .per-project-chart .pp-tooltip .tt-day { font-weight: 600; margin-bottom: 3px; color: var(--text); }
7456
+ .per-project-chart .pp-tooltip .tt-row { display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; color: var(--text); }
7457
+ .per-project-chart .pp-tooltip .tt-row .swatch { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
7458
+ .per-project-chart .pp-tooltip .tt-row .val { margin-left: auto; }
7459
+ .per-project-chart .pp-empty { padding: 14px 12px; color: var(--text-muted); font-size: 11px; text-align: center; }
7460
+ .per-project-row.pp-loading .per-project-chart .pp-empty { color: var(--text-secondary); }
6633
7461
  /* Deploy Health: slim inline bar when collapsed, alert colors when there is something worth attention */
6634
7462
  #deploy-health:not([open]) { margin-bottom: 10px; border-radius: 8px; }
6635
7463
  #deploy-health:not([open]) > summary { padding: 6px 14px; }
@@ -6662,6 +7490,24 @@ const HTML = `<!DOCTYPE html>
6662
7490
  .style-stats-pill:hover { border-color: var(--border-strong); background: var(--bg-hover); }
6663
7491
  .style-stats-pill.active { background: var(--accent-panel-bg); border-color: #3b82f6; color: var(--text); }
6664
7492
 
7493
+ /* In-place loading state for stats containers. Dims the previously-rendered
7494
+ grid/table so it stays visible (no layout jump) but reads clearly as stale.
7495
+ Used by loadActivityStats / loadCohortStats / loadStyleStats while their
7496
+ fetches are in flight. */
7497
+ .is-loading { opacity: 0.5; transition: opacity 0.12s linear; pointer-events: none; }
7498
+
7499
+ /* Disable Stats-tab filter pills while any stats fetch is in flight
7500
+ (body.sa-stats-busy) so users can't queue overlapping requests across
7501
+ rapid pill changes. Covers the three pill rows that drive
7502
+ reloadStatsTabSections: stats window + style-stats platform/project. */
7503
+ body.sa-stats-busy #stats-window-pills .style-stats-pill,
7504
+ body.sa-stats-busy #style-stats-platform-pills .style-stats-pill,
7505
+ body.sa-stats-busy #style-stats-project-pills .style-stats-pill {
7506
+ pointer-events: none;
7507
+ opacity: 0.55;
7508
+ cursor: wait;
7509
+ }
7510
+
6665
7511
  @media (max-width: 600px) { .cards { grid-template-columns: 1fr; } .content { padding: 16px; } }
6666
7512
 
6667
7513
  /* Client-mode auth overlay. Non-admin users see the app with admin-only
@@ -6912,7 +7758,7 @@ const HTML = `<!DOCTYPE html>
6912
7758
  </div>
6913
7759
  <details class="style-stats-section" id="cohort-stats" open>
6914
7760
  <summary>
6915
- <span class="style-stats-title"><span class="style-stats-caret">\u25B6</span><span id="cohort-stats-heading">Score Cohort Distribution (24h)</span><span class="stat-card-info" data-tooltip="Buckets posts by composite score (comments \u00D7 3 + upvotes; Reddit/Moltbook subtract 1 to strip OP self-upvote). Views are deliberately excluded from the score. Cohorts: Dead = 0, Low = 1-4, Mid = 5-14, High = 15+. Honors the Window / Platform / Project filters above.">i</span></span>
7761
+ <span class="style-stats-title"><span class="style-stats-caret">\u25B6</span><span id="cohort-stats-heading">Score Cohort Distribution (24h)</span><span class="stat-card-info" data-tooltip="Buckets posts by composite score (clicks \u00D7 10 + comments \u00D7 3 + upvotes; Reddit/Moltbook subtract 1 to strip OP self-upvote). Click weight \u00D710 because a real human click outvalues 10 likes of vibes (matches top_performers.SCORE_SQL and the engagement_styles.py picker). Views are deliberately excluded. Cohorts: Dead = 0, Low = 1\u20139, Mid = 10\u201329, High = 30+. Honors the Window / Platform / Project filters above.">i</span></span>
6916
7762
  <span class="style-stats-total" id="cohort-stats-total"></span>
6917
7763
  </summary>
6918
7764
  <div id="cohort-stats-body">
@@ -6990,6 +7836,16 @@ const HTML = `<!DOCTYPE html>
6990
7836
  <div id="daily-metrics-chart" class="daily-metrics-chart">
6991
7837
  <div class="views-chart-empty">Loading…</div>
6992
7838
  </div>
7839
+ <details class="per-project-breakdown" id="daily-metrics-per-project" data-scope="daily">
7840
+ <summary>
7841
+ <span class="pp-caret">▶</span>
7842
+ <span class="pp-label">Per-project breakdown</span>
7843
+ <span class="pp-status" id="daily-metrics-per-project-status"></span>
7844
+ </summary>
7845
+ <div class="per-project-rows" id="daily-metrics-per-project-rows">
7846
+ <div class="views-chart-empty">Click to load per-project charts.</div>
7847
+ </div>
7848
+ </details>
6993
7849
  </div>
6994
7850
  </details>
6995
7851
  <details class="style-stats-section" id="ratio-metrics" open>
@@ -7002,6 +7858,16 @@ const HTML = `<!DOCTYPE html>
7002
7858
  <div id="ratio-metrics-chart" class="daily-metrics-chart">
7003
7859
  <div class="views-chart-empty">Loading&hellip;</div>
7004
7860
  </div>
7861
+ <details class="per-project-breakdown" id="ratio-metrics-per-project" data-scope="ratio">
7862
+ <summary>
7863
+ <span class="pp-caret">&#9654;</span>
7864
+ <span class="pp-label">Per-project breakdown</span>
7865
+ <span class="pp-status" id="ratio-metrics-per-project-status"></span>
7866
+ </summary>
7867
+ <div class="per-project-rows" id="ratio-metrics-per-project-rows">
7868
+ <div class="views-chart-empty">Click to load per-project charts.</div>
7869
+ </div>
7870
+ </details>
7005
7871
  </div>
7006
7872
  </details>
7007
7873
  </div>
@@ -7096,6 +7962,11 @@ const HTML = `<!DOCTYPE html>
7096
7962
  <span class="top-subtab-label">DMs</span>
7097
7963
  <span class="top-subtab-sub">prospect chats</span>
7098
7964
  </span>
7965
+ <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.">
7966
+ <span class="top-subtab-icon" aria-hidden="true">\ud83d\udd17</span>
7967
+ <span class="top-subtab-label">Links</span>
7968
+ <span class="top-subtab-sub">destinations</span>
7969
+ </span>
7099
7970
  </div>
7100
7971
  <div class="top-controls">
7101
7972
  <input id="top-search" class="top-search" type="search" placeholder="Search posts\u2026" />
@@ -7197,6 +8068,9 @@ const HTML = `<!DOCTYPE html>
7197
8068
  <div id="top-dms-container" class="hidden">
7198
8069
  <div class="style-stats-empty">Loading\u2026</div>
7199
8070
  </div>
8071
+ <div id="top-links-container" class="hidden">
8072
+ <div class="style-stats-empty">Loading\u2026</div>
8073
+ </div>
7200
8074
  </div>
7201
8075
 
7202
8076
  <div class="content hidden" id="tab-logs">
@@ -7311,11 +8185,31 @@ function toast(msg, isError) {
7311
8185
  tipEl.style.left = left + 'px';
7312
8186
  tipEl.style.top = top + 'px';
7313
8187
  }
8188
+ // Render tooltip text with safe minimal markup:
8189
+ // 1. Escape all HTML special chars (so dynamic data can never inject HTML).
8190
+ // 2. Then turn double-asterisk sequences into bold. The escape step happens
8191
+ // FIRST, so even bolded angle-bracket text becomes inert.
8192
+ // 3. Newlines render via CSS white-space:pre-line (already on .sa-tooltip).
8193
+ // No conversion to br needed.
8194
+ // NOTE: this whole file sits inside the outer HTML backtick template, so all
8195
+ // escapes here must be DOUBLE backslashes — the outer template eats single
8196
+ // backslashes. The served JS sees single-backslash escapes, which the regex
8197
+ // engine then handles correctly.
8198
+ function renderTooltipMarkup(text) {
8199
+ var html = String(text)
8200
+ .replace(/&/g, '&amp;')
8201
+ .replace(/</g, '&lt;')
8202
+ .replace(/>/g, '&gt;')
8203
+ .replace(/"/g, '&quot;');
8204
+ // Non-greedy match so multiple **bold** runs on one line work.
8205
+ html = html.replace(/\\*\\*([^*\\n]+?)\\*\\*/g, '<b>$1</b>');
8206
+ return html;
8207
+ }
7314
8208
  function show(host) {
7315
8209
  const text = getText(host);
7316
8210
  if (!text) return;
7317
8211
  const el = ensureTip();
7318
- el.textContent = text;
8212
+ el.innerHTML = renderTooltipMarkup(text);
7319
8213
  el.classList.add('visible');
7320
8214
  position(host);
7321
8215
  currentHost = host;
@@ -7721,12 +8615,14 @@ function renderResult(run) {
7721
8615
  const unmatchedN = scan.unmatched || 0;
7722
8616
  const backfillN = scan.backfill || 0;
7723
8617
  const hasScan = scannedN || newN || excludedN || unmatchedN || backfillN;
8618
+ const SNL = String.fromCharCode(10);
7724
8619
  const scanTooltip = hasScan
7725
- ? ('inbox scan: seen=' + scannedN +
7726
- ' / new=' + newN +
7727
- ' / excluded=' + excludedN +
7728
- ' / unmatched=' + unmatchedN +
7729
- ' / backfill_skipped=' + backfillN)
8620
+ ? ('**Inbox scan**' + SNL + SNL +
8621
+ ' **seen:** ' + scannedN + SNL +
8622
+ ' **new:** ' + newN + SNL +
8623
+ ' **excluded:** ' + excludedN + SNL +
8624
+ ' **unmatched:** ' + unmatchedN + SNL +
8625
+ '• **backfill_skipped:** ' + backfillN)
7730
8626
  : '';
7731
8627
  const scanPills = hasScan
7732
8628
  ? ('<span title="' + scanTooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
@@ -7760,7 +8656,8 @@ function renderResult(run) {
7760
8656
  if (!failed && !reasons.length) return '';
7761
8657
  const top = reasons[0];
7762
8658
  const tt = reasons.length
7763
- ? reasons.map(function (x) { return x.reason + ' x' + x.count; }).join(', ')
8659
+ ? '**Failure reasons**' + String.fromCharCode(10) +
8660
+ reasons.map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; }).join(String.fromCharCode(10))
7764
8661
  : 'failed (no reason logged)';
7765
8662
  const label = top
7766
8663
  ? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
@@ -7770,12 +8667,16 @@ function renderResult(run) {
7770
8667
  'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
7771
8668
  label + (count ? ' <span style="color:var(--text);font-weight:600;">' + count + '</span>' : '') + '</span>';
7772
8669
  };
7773
- const tooltip = 'searches: ' + searches +
7774
- ' / raw SERP candidates: ' + raw +
7775
- ' / passed 20.0 floor: ' + passed +
7776
- ' / dropped below floor: ' + dropped +
7777
- ' / posted: ' + posted +
7778
- ' / pending queue: ' + queue;
8670
+ const LNL = String.fromCharCode(10);
8671
+ const tooltip =
8672
+ '**LinkedIn post-comment funnel**' + LNL +
8673
+ LNL +
8674
+ ' **searches:** ' + searches + LNL +
8675
+ ' **raw SERP candidates:** ' + raw + LNL +
8676
+ '• **passed 20.0 floor:** ' + passed + LNL +
8677
+ '• **dropped below floor:** ' + dropped + LNL +
8678
+ '• **posted:** ' + posted + LNL +
8679
+ '• **pending queue:** ' + queue;
7779
8680
  return (
7780
8681
  '<span title="' + tooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
7781
8682
  pill('searches', searches, searches > 0 ? 'var(--text)' : 'var(--muted)') +
@@ -7826,7 +8727,8 @@ function renderResult(run) {
7826
8727
  if (!failed && !reasons.length) return '';
7827
8728
  const top = reasons[0];
7828
8729
  const tt = reasons.length
7829
- ? reasons.map(function (x) { return x.reason + ' x' + x.count; }).join(', ')
8730
+ ? '**Failure reasons**' + String.fromCharCode(10) +
8731
+ reasons.map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; }).join(String.fromCharCode(10))
7830
8732
  : 'failed (no reason logged)';
7831
8733
  const label = top
7832
8734
  ? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
@@ -7855,31 +8757,60 @@ function renderResult(run) {
7855
8757
  'salvaged <span style="color:var(--text);font-weight:600;">' + salvPrimary + '</span>' +
7856
8758
  salvBracket +
7857
8759
  '</span>';
7858
- const tooltip = 'searches: ' + searches +
7859
- ' / raw tweets: ' + raw +
7860
- ' / passed score-time cuts: ' + passed +
7861
- ' / dropped pre-score (already-posted or age>18h): ' + dropped +
7862
- ' / expired (delta<1 floor): ' + expired +
7863
- ' / above review cap (delta>=10, gates POST_LIMIT=3): ' + aboveFloor +
7864
- ' / posted: ' + posted +
7865
- ' / Phase 0 salvaged into this cycle: ' + salvAttempted +
7866
- ' (of which posted: ' + salvPosted + ')' +
7867
- ' / salvageable now (pool size for next cycle): ' + salvageableLive +
7868
- ' (+' + salvAdded + ' became salvageable / -' + salvDrained + ' drained this run)' +
7869
- ' / pending end-of-run: ' + queue +
7870
- ' (start: ' + queueStart + ', +' + qAdded + ' added, -' + qDrained + ' drained = ' +
7871
- qDrainedPosted + ' posted + ' + qDrainedExpired + ' expired + ' + qDrainedSkipped + ' skipped)' +
7872
- ' / pending right now (live): ' + pendingLive;
8760
+ // Tooltip is grouped by cycle phase so the funnel reads chronologically.
8761
+ // String.fromCharCode(10) sidesteps the outer HTML backtick template
8762
+ // that strips literal backslash-n escapes (see feedback_server_js_template_regex).
8763
+ // CSS .sa-tooltip white-space:pre-line turns these into line breaks.
8764
+ const NL = String.fromCharCode(10);
8765
+ const tooltip =
8766
+ '**Phase 0 cleanup**' + NL +
8767
+ ' **salvaged ' + salvAttempted + '** — orphan rows adopted from prior dead cycles (' + salvPosted + ' posted this cycle)' + NL +
8768
+ ' pool for next cycle: **' + salvageableLive + '** salvageable (+' + salvAdded + ' / -' + salvDrained + ' this run)' + NL +
8769
+ NL +
8770
+ '**Phase 1 scrape**' + NL +
8771
+ ' **searches ' + searches + '** — queries run' + NL +
8772
+ ' **raw ' + raw + '** tweets returned' + NL +
8773
+ '• **passed ' + passed + '** after dedup + age>18h cuts (' + dropped + ' dropped)' + NL +
8774
+ NL +
8775
+ '**Phase 2a — Δ re-score**' + NL +
8776
+ '• **expired ' + expired + '** — below Δ<1 likes floor' + NL +
8777
+ NL +
8778
+ '**Phase 2b — draft + post**' + NL +
8779
+ '• **Δ≥10: ' + aboveFloor + '** — crossed POST_LIMIT=3 review cap' + NL +
8780
+ '• **posted ' + posted + '** — shipped' + NL +
8781
+ '• **failed ' + failed + '** — post errors' + NL +
8782
+ NL +
8783
+ '**Pending end-of-run:** ' + queue + NL +
8784
+ ' start ' + queueStart + ', +' + qAdded + ' / -' + qDrained + ' = ' +
8785
+ qDrainedPosted + ' posted, ' + qDrainedExpired + ' expired, ' + qDrainedSkipped + ' skipped' + NL +
8786
+ '**Pending live:** ' + pendingLive;
8787
+ // Pill order mirrors the tooltip story: salvaged (Phase 0 input) leads,
8788
+ // then Phase 1 funnel (searches, raw, passed), Phase 2a drop (expired),
8789
+ // Phase 2b decision and outcome (Δ≥10, posted, failed).
7873
8790
  return (
7874
8791
  '<span title="' + tooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
8792
+ queuePill +
7875
8793
  pill('searches', searches, searches > 0 ? 'var(--text)' : 'var(--muted)') +
7876
8794
  pill('raw', raw, raw > 0 ? 'var(--text)' : 'var(--muted)') +
7877
8795
  pill('passed', passed, passed > 0 ? '#22c55e' : 'var(--muted)') +
7878
8796
  pill('expired', expired, expired > 0 ? 'var(--text)' : 'var(--muted)') +
7879
8797
  pill('Δ≥10', aboveFloor, aboveFloor > 0 ? '#a78bfa' : 'var(--muted)') +
7880
8798
  pill('posted', posted, posted > 0 ? '#22c55e' : 'var(--muted)') +
7881
- queuePill +
7882
8799
  renderFailedPill() +
8800
+ (Array.isArray(r.projects_worked) && r.projects_worked.length
8801
+ ? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
8802
+ + 'projects '
8803
+ + '<span style="color:var(--text);font-weight:600;">'
8804
+ + r.projects_worked.join(', ')
8805
+ + '</span></span>'
8806
+ : '') +
8807
+ (Array.isArray(r.styles_used) && r.styles_used.length
8808
+ ? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
8809
+ + 'styles '
8810
+ + '<span style="color:var(--text);font-weight:600;">'
8811
+ + r.styles_used.join(', ')
8812
+ + '</span></span>'
8813
+ : '') +
7883
8814
  '</span>'
7884
8815
  );
7885
8816
  }
@@ -7936,7 +8867,8 @@ function renderResult(run) {
7936
8867
  ? ' (' + reasons.map(function (x) { return x.reason + ' ' + x.count; }).join('; ') + ')'
7937
8868
  : '';
7938
8869
  const tt = reasons.length
7939
- ? reasons.map(function (x) { return x.reason + ': ' + x.count; }).join(', ')
8870
+ ? '**Failure reasons**' + String.fromCharCode(10) +
8871
+ reasons.map(function (x) { return '• ' + x.reason + ': **' + x.count + '**'; }).join(String.fromCharCode(10))
7940
8872
  : 'failed (no reason logged)';
7941
8873
  return '<span title="' + tt.replace(/"/g, '&quot;') + '" ' +
7942
8874
  'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
@@ -7960,17 +8892,28 @@ function renderResult(run) {
7960
8892
  // now means "threads that ACTUALLY came through the ripen step
7961
8893
  // with positive momentum", which is what the word implies in plain
7962
8894
  // English. Final chain: raw → passed → ripened → drafted → posted.
7963
- const ripenTip = 'ripen iters: ' + ripenIters +
7964
- ' / input decisions (=passed): ' + ripenInput +
7965
- ' / ripened (composite > ' + (ripenFloor != null ? ripenFloor : '?') + '): ' + ripenSurvivors +
7966
- ' / drops: ' + ripenDrops +
7967
- ' / iters skipped (0 survivors): ' + ripenSkipped +
7968
- ' / iters passthrough (no urls / rate limit): ' + ripenPassthrough +
7969
- (ripenWindow != null ? ' / window: ' + ripenWindow + 's' : '') +
7970
- (ripenW != null ? ' / formula: Δup + ' + ripenW + '*Δcomments' : '') +
7971
- (bestComp != null ? ' / best: composite=' + bestComp.toFixed(1) +
7972
- ' (Δup=' + (bestDup != null ? bestDup : '?') +
7973
- ', Δcomm=' + (bestDco != null ? bestDco : '?') + ')' : '');
8895
+ // Build the ripen tooltip with explicit newlines (NL is defined in the
8896
+ // outer scope; use String.fromCharCode(10) here in case the closure
8897
+ // walked away from it). See feedback_server_js_template_regex memory:
8898
+ // literal backslash-n inside the outer HTML backtick template gets eaten,
8899
+ // so we build the newline char numerically.
8900
+ const RNL = String.fromCharCode(10);
8901
+ const ripenTip =
8902
+ '**Ripen step** 5-min Δ re-score' + RNL +
8903
+ RNL +
8904
+ ' **iterations:** ' + ripenIters + RNL +
8905
+ ' **input decisions** (= passed): ' + ripenInput + RNL +
8906
+ '• **ripened** (composite > ' + (ripenFloor != null ? ripenFloor : '?') + '): ' + ripenSurvivors + RNL +
8907
+ '• **drops:** ' + ripenDrops + RNL +
8908
+ '• **iters skipped** (0 survivors): ' + ripenSkipped + RNL +
8909
+ '• **iters passthrough** (no urls / rate limit): ' + ripenPassthrough +
8910
+ (ripenWindow != null ? RNL + RNL + '**window:** ' + ripenWindow + 's' : '') +
8911
+ (ripenW != null ? RNL + '**formula:** Δup + ' + ripenW + '×Δcomments' : '') +
8912
+ (bestComp != null
8913
+ ? RNL + RNL + '**best:** composite=' + bestComp.toFixed(1) +
8914
+ ' (Δup=' + (bestDup != null ? bestDup : '?') +
8915
+ ', Δcomm=' + (bestDco != null ? bestDco : '?') + ')'
8916
+ : '');
7974
8917
  const bestLabel = bestComp != null
7975
8918
  ? ('best Δ' + bestComp.toFixed(1))
7976
8919
  : (ripenSurvivors > 0 ? 'best Δ?' : 'no Δ');
@@ -8027,35 +8970,55 @@ function renderResult(run) {
8027
8970
  bracket = ' <span style="color:var(--muted);font-weight:400;">(' +
8028
8971
  '+' + salvAdded + '/-' + salvDrained + ' pool)</span>';
8029
8972
  }
8030
- const qTip = 'salvage attempts this run (Phase 0 row pulls): ' + salvAttempted +
8031
- ' / salvage lane posted: ' + salvPosted +
8032
- ' / salvage lane failed: ' + salvFailedNow +
8033
- ' / salvageable now (pool size for next cycle): ' + salvageableLive +
8034
- ' (+' + salvAdded + ' became salvageable / -' + salvDrained + ' drained out)' +
8035
- ' / pending pool end-of-run: ' + queue + ' (start: ' + queueStartV +
8036
- ', +' + qAdded + ' added, -' + qDrained + ' drained = ' +
8037
- qDrainedPosted + ' posted + ' + qDrainedFailed + ' failed + ' +
8038
- qDrainedExpired + ' expired + ' + qDrainedSkipped + ' skipped)' +
8039
- ' / pending right now (live): ' + pendingLive;
8973
+ const QNL = String.fromCharCode(10);
8974
+ const qTip =
8975
+ '**Salvage lane** Phase 0 cleanup' + QNL +
8976
+ QNL +
8977
+ ' **attempts this run:** ' + salvAttempted + QNL +
8978
+ ' **posted:** ' + salvPosted + QNL +
8979
+ ' **failed:** ' + salvFailedNow + QNL +
8980
+ QNL +
8981
+ '**Salvageable pool** (next cycle): ' + salvageableLive + QNL +
8982
+ ' +' + salvAdded + ' became salvageable, -' + salvDrained + ' drained out' + QNL +
8983
+ QNL +
8984
+ '**Pending pool end-of-run:** ' + queue + QNL +
8985
+ ' start: ' + queueStartV + ', +' + qAdded + ' added, -' + qDrained + ' drained' + QNL +
8986
+ ' drained = ' + qDrainedPosted + ' posted + ' + qDrainedFailed + ' failed + ' +
8987
+ qDrainedExpired + ' expired + ' + qDrainedSkipped + ' skipped' + QNL +
8988
+ QNL +
8989
+ '**Pending live now:** ' + pendingLive;
8040
8990
  return '<span title="' + qTip.replace(/"/g, '&quot;') + '" ' +
8041
8991
  'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
8042
8992
  'salvaged <span style="color:var(--text);font-weight:600;">' + primaryN + '</span>' +
8043
8993
  bracket + '</span>';
8044
8994
  };
8045
8995
 
8046
- const tooltip = 'iterations: ' + iterations +
8047
- ' / searches: ' + searches +
8048
- ' / raw API results: ' + raw +
8049
- ' / passed (post-API filter): ' + passed +
8050
- ' / dropped (blocked sub / archived / locked / age>180d): ' + dropped +
8051
- ' / fetched (model opened to read): ' + fetched +
8052
- ' / drafted: ' + drafted +
8053
- (ripenIters ? ' / ripen survivors: ' + ripenSurvivors + '/' + ripenInput +
8054
- (bestComp != null ? ' (best composite ' + bestComp.toFixed(1) + ')' : '') : '') +
8055
- ' / posted: ' + posted +
8056
- (salvageableLive ? ' / salvageable in DB: ' + salvageableLive : '');
8996
+ const RDNL = String.fromCharCode(10);
8997
+ const tooltip =
8998
+ '**Reddit post-comment funnel**' + RDNL +
8999
+ RDNL +
9000
+ ' **iterations:** ' + iterations + RDNL +
9001
+ ' **searches:** ' + searches + RDNL +
9002
+ ' **raw API results:** ' + raw + RDNL +
9003
+ ' **passed** (post-API filter): ' + passed + RDNL +
9004
+ '• **dropped** (blocked sub / archived / locked / age>180d): ' + dropped + RDNL +
9005
+ ' **fetched** (model opened to read): ' + fetched + RDNL +
9006
+ ' **drafted:** ' + drafted +
9007
+ (ripenIters
9008
+ ? RDNL + '• **ripen survivors:** ' + ripenSurvivors + ' / ' + ripenInput +
9009
+ (bestComp != null ? ' (best composite ' + bestComp.toFixed(1) + ')' : '')
9010
+ : '') + RDNL +
9011
+ '• **posted:** ' + posted +
9012
+ (salvageableLive ? RDNL + RDNL + '**Salvageable in DB:** ' + salvageableLive : '');
8057
9013
  return (
8058
9014
  '<span title="' + tooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
9015
+ // 2026-05-19: salvaged pill leads, mirroring Twitter chronological
9016
+ // pill order (server.js ~line 8723). Phase 0 salvage runs FIRST in
9017
+ // every reddit cycle (run-reddit-search.sh:241), so it belongs at the
9018
+ // start of the funnel, not buried after the posted pill. Twitter
9019
+ // renderer comment says: pill order mirrors the tooltip story,
9020
+ // salvaged (Phase 0 input) leads, then Phase 1 funnel, Phase 2b.
9021
+ renderQueuePill() +
8059
9022
  pill('iterations', iterations, iterations > 0 ? 'var(--text)' : 'var(--muted)') +
8060
9023
  pill('searches', searches, searches > 0 ? 'var(--text)' : 'var(--muted)') +
8061
9024
  pill('raw', raw, raw > 0 ? 'var(--text)' : 'var(--muted)') +
@@ -8069,7 +9032,6 @@ function renderResult(run) {
8069
9032
  renderRipenPills() +
8070
9033
  pill('drafted', drafted, drafted > 0 ? 'var(--text)' : 'var(--muted)') +
8071
9034
  pill('posted', posted, posted > 0 ? '#22c55e' : 'var(--muted)') +
8072
- renderQueuePill() +
8073
9035
  renderFailedPill() +
8074
9036
  (Array.isArray(r.projects_worked) && r.projects_worked.length
8075
9037
  ? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
@@ -8078,6 +9040,13 @@ function renderResult(run) {
8078
9040
  + r.projects_worked.join(', ')
8079
9041
  + '</span></span>'
8080
9042
  : '') +
9043
+ (Array.isArray(r.styles_used) && r.styles_used.length
9044
+ ? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
9045
+ + 'styles '
9046
+ + '<span style="color:var(--text);font-weight:600;">'
9047
+ + r.styles_used.join(', ')
9048
+ + '</span></span>'
9049
+ : '') +
8081
9050
  '</span>'
8082
9051
  );
8083
9052
  }
@@ -8105,7 +9074,8 @@ function renderResult(run) {
8105
9074
  if (!failed) return '';
8106
9075
  const top = reasons[0];
8107
9076
  const tooltip = reasons.length
8108
- ? reasons.map(function (x) { return x.reason + ' x' + x.count; }).join(', ')
9077
+ ? '**Failure reasons**' + String.fromCharCode(10) +
9078
+ reasons.map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; }).join(String.fromCharCode(10))
8109
9079
  : 'failed (no reason logged)';
8110
9080
  const label = top
8111
9081
  ? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
@@ -8125,12 +9095,14 @@ function renderResult(run) {
8125
9095
  const unmatchedN = scan.unmatched || 0;
8126
9096
  const backfillN = scan.backfill || 0;
8127
9097
  const hasScan = scannedN || newN || excludedN || unmatchedN || backfillN;
9098
+ const SNL = String.fromCharCode(10);
8128
9099
  const scanTooltip = hasScan
8129
- ? ('inbox scan: seen=' + scannedN +
8130
- ' / new=' + newN +
8131
- ' / excluded=' + excludedN +
8132
- ' / unmatched=' + unmatchedN +
8133
- ' / backfill_skipped=' + backfillN)
9100
+ ? ('**Inbox scan**' + SNL + SNL +
9101
+ ' **seen:** ' + scannedN + SNL +
9102
+ ' **new:** ' + newN + SNL +
9103
+ ' **excluded:** ' + excludedN + SNL +
9104
+ ' **unmatched:** ' + unmatchedN + SNL +
9105
+ '• **backfill_skipped:** ' + backfillN)
8134
9106
  : '';
8135
9107
  const scanPills = hasScan
8136
9108
  ? ('<span title="' + scanTooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
@@ -8153,6 +9125,26 @@ function renderResult(run) {
8153
9125
  // the old "posted=18216" pill was the total active-posts count from the
8154
9126
  // DB, which had nothing to do with what the run did. Render the real
8155
9127
  // per-run counters parsed out of the stats log instead.
9128
+ //
9129
+ // 2026-05-18 relabel: split the misleading single "updated" pill into
9130
+ // four explicit pills (scanned / checked / changed / views) so the
9131
+ // operator can read at a glance what the run actually did:
9132
+ //
9133
+ // scanned -> total rows considered (= polled + every flavor of skip)
9134
+ // skipped -> rows we deliberately did NOT poll (covered by Step 1
9135
+ // scrape or stable cooldown). Saves API calls.
9136
+ // checked -> rows we actually hit the platform API for this run.
9137
+ // changed -> subset of "checked" where any tracked metric moved.
9138
+ // Was the original intent of "updated" but the legacy
9139
+ // field also summed in the Step 1 view-scrape count.
9140
+ // views -> Step 1 scrape leg only: rows where the cheap profile-
9141
+ // page scrape wrote a fresh view count (Reddit) or
9142
+ // fxtwitter view (Twitter). Distinct from "changed".
9143
+ // replies -> per-reply rows refreshed (DM-rail follow-ups we made
9144
+ // on someone else thread, live in the "replies" table).
9145
+ //
9146
+ // Each pill carries data-tooltip so hovering surfaces the meaning
9147
+ // line-by-line via the global .sa-tooltip handler.
8156
9148
  if (run.job_type === 'stats') {
8157
9149
  const checked = r.checked || 0;
8158
9150
  const updated = r.updated || 0;
@@ -8162,19 +9154,77 @@ function renderResult(run) {
8162
9154
  const skipped = r.skipped || 0;
8163
9155
  const failed = r.failed || 0;
8164
9156
  const repliesRefreshed = r.replies_refreshed || 0;
9157
+ // New 2026-05-18 fields; fall back to derived values when the log line
9158
+ // pre-dates the relabel pass (so historical rows still render sanely).
9159
+ const scanned = r.scanned || (checked + skipped) || 0;
9160
+ const changed = r.changed || 0;
9161
+ const viewsRefreshed = r.views_refreshed || 0;
8165
9162
  if (!checked && !updated && !removed && !unavailable && !notFound &&
8166
- !skipped && !failed && !repliesRefreshed) {
9163
+ !skipped && !failed && !repliesRefreshed &&
9164
+ !scanned && !changed && !viewsRefreshed) {
8167
9165
  return '<span style="color:var(--muted);font-size:12px;">—</span>';
8168
9166
  }
9167
+ // Inline helper: pill with a data-tooltip attribute for per-pill hover
9168
+ // explanations. Plain pill() (above) has no tooltip slot; this is
9169
+ // local to stats-job rendering only.
9170
+ const tipPill = (label, n, color, tip) => {
9171
+ const tipEsc = (tip || '').replace(/"/g, '&quot;');
9172
+ return '<span data-tooltip="' + tipEsc + '" style="display:inline-block;' +
9173
+ 'margin-right:10px;font-size:12px;color:var(--muted);cursor:help;">' +
9174
+ label + ' <span style="color:' + (color || 'var(--text)') +
9175
+ ';font-weight:600;">' + n + '</span></span>';
9176
+ };
8169
9177
  return (
8170
- pill('checked', checked, 'var(--text)') +
8171
- pill('updated', updated, '#22c55e') +
8172
- (removed ? pill('removed', removed, '#eab308') : '') +
8173
- (unavailable ? pill('unavail', unavailable, '#eab308') : '') +
8174
- (notFound ? pill('not found', notFound, 'var(--muted)') : '') +
8175
- (skipped ? pill('skipped', skipped, 'var(--muted)') : '') +
8176
- (repliesRefreshed ? pill('replies', repliesRefreshed, '#3b82f6') : '') +
8177
- (failed ? pill('failed', failed, '#ef4444') : '')
9178
+ tipPill('scanned', scanned, 'var(--text)',
9179
+ '**Scanned**\\n\\n' +
9180
+ 'Total rows the run considered (every active row in the relevant platform tables).\\n\\n' +
9181
+ '= checked + skipped + bypassed-as-fresh.') +
9182
+ (skipped ? tipPill('skipped', skipped, 'var(--muted)',
9183
+ '**Skipped**\\n\\n' +
9184
+ 'Rows we deliberately did NOT poll this run.\\n\\n' +
9185
+ '**Two reasons:**\\n' +
9186
+ '• already refreshed by the cheap scrape leg within the last 4h\\n' +
9187
+ '• stable cooldown (2+ scans with no metric change AND older than 3 days)\\n\\n' +
9188
+ 'Saves API calls; data is still current.') : '') +
9189
+ tipPill('checked', checked, 'var(--text)',
9190
+ '**Checked**\\n\\n' +
9191
+ 'Rows we actually hit the platform API for this run (Reddit old.reddit.com JSON / fxtwitter / LinkedIn activity feed).\\n\\n' +
9192
+ 'Includes both successful polls and the ones that errored mid-fetch.') +
9193
+ tipPill('changed', changed, '#22c55e',
9194
+ '**Changed**\\n\\n' +
9195
+ 'Subset of CHECKED where any tracked metric (upvotes, comments_count, views) actually moved since the prior scan.\\n\\n' +
9196
+ 'The real-activity signal; everything else is no-op polling.') +
9197
+ (viewsRefreshed ? tipPill('views', viewsRefreshed, '#06b6d4',
9198
+ '**Views refreshed**\\n\\n' +
9199
+ 'Rows where the cheap view-scrape leg wrote a fresh view count this run.\\n\\n' +
9200
+ '• **Reddit:** Step 1 profile-page scrape (sees every comment + thread on /user/<name>/)\\n' +
9201
+ '• **Twitter:** built-in to the fxtwitter call\\n\\n' +
9202
+ 'Separate from CHANGED because views can tick up without upvotes/comments moving.') : '') +
9203
+ (repliesRefreshed ? tipPill('replies', repliesRefreshed, '#3b82f6',
9204
+ '**Replies refreshed**\\n\\n' +
9205
+ 'Per-reply rows refreshed: comments we authored AS replies to someone else replying to our original comment (the DM-rail follow-up). Live in the replies table, not posts.\\n\\n' +
9206
+ '• **Reddit:** refreshes upvotes + reply-count via batch JSON API\\n' +
9207
+ '• **Twitter:** also refreshes views via fxtwitter') : '') +
9208
+ (removed ? tipPill('removed', removed, '#eab308',
9209
+ '**Removed**\\n\\n' +
9210
+ 'Posts newly flagged deleted/removed this run.\\n\\n' +
9211
+ '• **Reddit:** comment gone from thread JSON for 2+ consecutive scans (deletion_detect_count threshold)\\n' +
9212
+ '• **LinkedIn:** post returned "Post unavailable"') : '') +
9213
+ (unavailable ? tipPill('unavail', unavailable, '#eab308',
9214
+ '**Unavailable** (LinkedIn only)\\n\\n' +
9215
+ 'Post explicitly returned a Post Unavailable string.\\n\\n' +
9216
+ 'Subset of REMOVED; rendered as its own pill so an operator can tell hard-deletion from rate-limit or network noise.') : '') +
9217
+ (notFound ? tipPill('not found', notFound, 'var(--muted)',
9218
+ '**Not found** (LinkedIn only)\\n\\n' +
9219
+ 'Post is still active on LinkedIn but our specific comment could not be located on the activity feed (may have aged off our visible recent-activity window).') : '') +
9220
+ (failed ? tipPill('failed', failed, '#ef4444',
9221
+ '**Failed**\\n\\n' +
9222
+ 'API errors during the run, broken down by category:\\n' +
9223
+ '• 404 not_found\\n' +
9224
+ '• rate-limited (429)\\n' +
9225
+ '• empty / malformed response\\n' +
9226
+ '• other / network\\n\\n' +
9227
+ 'Includes step-exit failures from the shell pipeline as well.') : '')
8178
9228
  );
8179
9229
  }
8180
9230
  // seo_expire (delete dead-weight pages): repurposes posted/skipped from the
@@ -8192,7 +9242,8 @@ function renderResult(run) {
8192
9242
  if (!failed && !reasons.length) return '';
8193
9243
  const top = reasons[0];
8194
9244
  const tt = reasons.length
8195
- ? reasons.map(function (x) { return x.reason + ' x' + x.count; }).join(', ')
9245
+ ? '**Failure reasons**' + String.fromCharCode(10) +
9246
+ reasons.map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; }).join(String.fromCharCode(10))
8196
9247
  : 'failed (no reason logged)';
8197
9248
  const label = top
8198
9249
  ? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
@@ -8202,10 +9253,16 @@ function renderResult(run) {
8202
9253
  'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
8203
9254
  label + (count ? ' <span style="color:var(--text);font-weight:600;">' + count + '</span>' : '') + '</span>';
8204
9255
  };
8205
- const tooltip = 'total pages in scope (zero-click 30+ days, all projects): ' + total +
8206
- ' / deleted this run: ' + deleted +
8207
- ' / queue (waiting for next run, capped by DAILY_MAX): ' + queue +
8208
- ' / failed: ' + failed;
9256
+ const ENL = String.fromCharCode(10);
9257
+ const tooltip =
9258
+ '**SEO page expire**' + ENL +
9259
+ ENL +
9260
+ '• **total pages in scope:** ' + total + ENL +
9261
+ ' (zero-click 30+ days, all projects)' + ENL +
9262
+ '• **deleted this run:** ' + deleted + ENL +
9263
+ '• **queue:** ' + queue + ENL +
9264
+ ' (waiting for next run, capped by DAILY_MAX)' + ENL +
9265
+ '• **failed:** ' + failed;
8209
9266
  return (
8210
9267
  '<span title="' + tooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
8211
9268
  pill('total pages', total, total > 0 ? 'var(--text)' : 'var(--muted)') +
@@ -8230,7 +9287,8 @@ function renderResult(run) {
8230
9287
  if (!failed && !reasons.length) return '';
8231
9288
  const top = reasons[0];
8232
9289
  const tooltip = reasons.length
8233
- ? reasons.map(function (x) { return x.reason + ' x' + x.count; }).join(', ')
9290
+ ? '**Failure reasons**' + String.fromCharCode(10) +
9291
+ reasons.map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; }).join(String.fromCharCode(10))
8234
9292
  : 'failed (no reason logged)';
8235
9293
  const label = top
8236
9294
  ? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
@@ -8247,8 +9305,10 @@ function renderResult(run) {
8247
9305
  const renderSkipReasonsPill = () => {
8248
9306
  if (!skipBreakdown.length) return '';
8249
9307
  const top = skipBreakdown[0];
8250
- const tooltip = skipBreakdown
8251
- .map(function (x) { return x.reason + ' x' + x.count; }).join(', ');
9308
+ const tooltip = '**Skip reasons**' + String.fromCharCode(10) +
9309
+ skipBreakdown
9310
+ .map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; })
9311
+ .join(String.fromCharCode(10));
8252
9312
  const label = 'skipped: ' + top.reason +
8253
9313
  (skipBreakdown.length > 1 ? ' +' + (skipBreakdown.length - 1) : '');
8254
9314
  const count = top.count;
@@ -8270,10 +9330,17 @@ function renderResult(run) {
8270
9330
  const tp = discover.tweets_pulled || 0;
8271
9331
  const c = discover.candidates || 0;
8272
9332
  const af = discover.above_floor || 0;
8273
- const tip = (q + ' queries' + (d ? ' (' + d + ' duds)' : '')) + ' \u2192 ' +
8274
- (tp + ' tweets pulled') + ' \u2192 ' +
8275
- (c + ' candidates after floor') + ' \u2192 ' +
8276
- (af + ' cleared review cap');
9333
+ const DNL = String.fromCharCode(10);
9334
+ const tip =
9335
+ '**Discovery funnel**' + DNL +
9336
+ DNL +
9337
+ '• **' + q + ' queries**' + (d ? ' (' + d + ' duds)' : '') + DNL +
9338
+ ' \u2193' + DNL +
9339
+ '• **' + tp + ' tweets pulled**' + DNL +
9340
+ ' \u2193' + DNL +
9341
+ '• **' + c + ' candidates** after floor' + DNL +
9342
+ ' \u2193' + DNL +
9343
+ '• **' + af + ' cleared** review cap';
8277
9344
  const visStr = q + '\u2192' + c + '\u2192' + af;
8278
9345
  const color = (q || c || af) ? 'var(--text)' : 'var(--muted)';
8279
9346
  return '<span title="' + tip.replace(/"/g, '&quot;') + '" ' +
@@ -8332,7 +9399,7 @@ function buildSeoDetailRows(run) {
8332
9399
  if (!details.length) return '';
8333
9400
  const subRows = details.map(d => {
8334
9401
  const cost = (typeof d.cost_usd === 'number' && d.cost_usd > 0)
8335
- ? fmtCostCell(d.cost_usd, d.cost_usd_orchestrator, d.cost_usd_estimated)
9402
+ ? fmtCostCell(d.cost_usd, d.cost_usd_orchestrator, d.cost_usd_estimated, d.cost_usd_subagent)
8336
9403
  : '<span style="color:var(--muted);">—</span>';
8337
9404
  const turns = (typeof d.num_turns === 'number' && d.num_turns > 0)
8338
9405
  ? d.num_turns
@@ -8378,6 +9445,85 @@ function buildSeoDetailRows(run) {
8378
9445
  );
8379
9446
  }
8380
9447
 
9448
+ // Cost cell for a Job History row. SDK-only mode (2026-05-15): the headline
9449
+ // total is the sum of orchestrator_cost_usd across the phases of this run.
9450
+ // Sessions whose wrapper didn't capture SDK cost contribute 0; the per-phase
9451
+ // breakdown surfaces a "missing SDK" count so the operator can spot pipelines
9452
+ // where real spend went unrecorded (the cost cell shows the smaller real
9453
+ // number, not an inflated estimate).
9454
+ function _jobHistoryCostCell(result) {
9455
+ const fmtLane = (v) => {
9456
+ if (v == null) return 'n/a';
9457
+ const n = Number(v);
9458
+ if (!isFinite(n)) return 'n/a';
9459
+ if (n === 0) return '$0';
9460
+ if (n < 0.01) return '$' + n.toFixed(4);
9461
+ return '$' + n.toFixed(4);
9462
+ };
9463
+ const bd = result.cost_breakdown;
9464
+ const orch = result.cost_usd_orchestrator != null ? Number(result.cost_usd_orchestrator) : null;
9465
+ const sub = result.cost_usd_subagent != null ? Number(result.cost_usd_subagent) : null;
9466
+ const est = result.cost_usd_estimated != null ? Number(result.cost_usd_estimated) : null;
9467
+ const sessionsAll = bd ? Number(bd.sessions) || 0 : 0;
9468
+ const sessionsWithSdk = bd ? Number(bd.sessions_with_sdk) || 0 : 0;
9469
+ const sessionsMissing = Math.max(0, sessionsAll - sessionsWithSdk);
9470
+ const totalForDisplay = orch != null ? orch : 0;
9471
+ // Header value: "n/a" when no SDK data captured for any of the run's
9472
+ // sessions, otherwise the orchestrator sum (with a "(N missing SDK)"
9473
+ // hint inline when partial).
9474
+ let headerHtml;
9475
+ if (sessionsAll === 0) {
9476
+ headerHtml = '<span style="color:var(--text-very-faint);">n/a</span>';
9477
+ } else if (sessionsWithSdk === 0) {
9478
+ headerHtml = '<span style="color:var(--text-very-faint);">n/a</span>';
9479
+ } else if (sessionsMissing > 0) {
9480
+ headerHtml = fmtCost(totalForDisplay) +
9481
+ ' <span style="color:#eab308;font-size:11px;">(' + sessionsMissing + ' missing)</span>';
9482
+ } else {
9483
+ headerHtml = fmtCost(totalForDisplay);
9484
+ }
9485
+ const lines = [
9486
+ '**Cost (SDK orchestrator):** ' + (sessionsWithSdk > 0 ? fmtLane(orch) : 'n/a'),
9487
+ ];
9488
+ if (sessionsAll > 0) {
9489
+ lines.push('• Sessions: **' + sessionsAll + '**');
9490
+ lines.push('• with SDK data: **' + sessionsWithSdk + '**');
9491
+ lines.push('• missing SDK: **' + sessionsMissing + '**');
9492
+ }
9493
+ if (bd && Array.isArray(bd.phases) && bd.phases.length) {
9494
+ lines.push('');
9495
+ lines.push('**Per-phase** (claude_sessions.script grouping)');
9496
+ const shown = bd.phases.slice(0, 10);
9497
+ for (const p of shown) {
9498
+ const missing = (p.sessions_missing_sdk && p.sessions_missing_sdk > 0)
9499
+ ? (' [' + p.sessions_missing_sdk + ' missing SDK]')
9500
+ : '';
9501
+ const orchVal = (p.sessions_with_sdk && p.sessions_with_sdk > 0)
9502
+ ? fmtLane(p.orch)
9503
+ : 'n/a';
9504
+ lines.push('• ' + (p.phase || '(unknown)') + ' ×' + p.sessions +
9505
+ ' ' + orchVal + missing);
9506
+ }
9507
+ if (bd.phases.length > shown.length) {
9508
+ lines.push(' …(' + (bd.phases.length - shown.length) + ' more)');
9509
+ }
9510
+ }
9511
+ if (typeof result.cost_usd_from_log === 'number') {
9512
+ lines.push('');
9513
+ lines.push('**Wrapper shell-log value:** ' + fmtLane(result.cost_usd_from_log));
9514
+ }
9515
+ lines.push('');
9516
+ lines.push('**Diagnostic-only** (local pricing estimate, not actual billing)');
9517
+ lines.push('• Transcript estimate: ' + fmtLane(est));
9518
+ lines.push('• Subagent (est): ' + fmtLane(sub));
9519
+ lines.push('');
9520
+ 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.');
9521
+ const tip = lines.join('\\n');
9522
+ return '<span data-tooltip="' + escapeHtml(tip) +
9523
+ '" style="cursor:help;border-bottom:1px dotted var(--text-muted);">' +
9524
+ headerHtml + '</span>';
9525
+ }
9526
+
8381
9527
  // Stable identity for a job-history row across polls. (script, started_at)
8382
9528
  // is unique in practice; pid is appended as a tiebreaker for the rare case
8383
9529
  // where two parallel fires of the same script land in the same second.
@@ -8406,8 +9552,16 @@ function _jobHistoryRowSig(r) {
8406
9552
  ].join('|');
8407
9553
  }
8408
9554
  function _buildJobHistoryRowGroup(r, idx) {
8409
- const cost = r.result && r.result.cost_usd;
8410
- const costCell = cost ? fmtCost(cost) : '<span style="color:var(--muted);">—</span>';
9555
+ // SDK-only mode: render the cost cell whenever the run produced any
9556
+ // claude_sessions rows (cost_breakdown is attached), even if the SDK lane
9557
+ // is NULL across all of them — the tooltip surfaces "missing SDK" so the
9558
+ // operator sees pipelines whose wrappers haven't been patched yet.
9559
+ const hasBreakdown = r.result && r.result.cost_breakdown
9560
+ && Number(r.result.cost_breakdown.sessions) > 0;
9561
+ const hasLogCost = r.result && typeof r.result.cost_usd === 'number';
9562
+ const costCell = (hasBreakdown || hasLogCost)
9563
+ ? _jobHistoryCostCell(r.result)
9564
+ : '<span style="color:var(--muted);">—</span>';
8411
9565
  const hasDetails = Array.isArray(r.details) && r.details.length;
8412
9566
  const caret = hasDetails
8413
9567
  ? '<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> '
@@ -8972,8 +10126,12 @@ async function saveSettings() {
8972
10126
  }
8973
10127
 
8974
10128
  // Activity tab
8975
- 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'];
8976
- 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' };
10129
+ // Page-generation event types. Folded into a single 'pages_generated' card
10130
+ // in renderActivityStats (with per-pipeline breakdown in the body + tooltip).
10131
+ // SQL still emits each subtype so the breakdown is faithful.
10132
+ 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'];
10133
+ const EVENT_TYPES = ['posted_thread', 'posted_comment', 'replied', 'skipped', 'mention', 'dm_sent', 'dm_reply_sent', ...PAGE_GEN_EVENT_TYPES, 'page_expired', 'resurrected'];
10134
+ 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' };
8977
10135
  const EVENT_DESCRIPTIONS = {
8978
10136
  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.',
8979
10137
  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).',
@@ -8982,13 +10140,15 @@ const EVENT_DESCRIPTIONS = {
8982
10140
  mention: 'Someone mentioned one of our products on a tracked platform. Detection only, no engagement action.',
8983
10141
  dm_sent: 'New direct-message conversation the bot started with a prospect.',
8984
10142
  dm_reply_sent: 'Follow-up message sent inside an existing DM conversation.',
8985
- page_published_serp: 'SEO landing page generated from the SERP pipeline (based on ranked search results for target keywords).',
10143
+ 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.',
10144
+ 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.',
8986
10145
  page_published_gsc: 'SEO page generated from a Google Search Console query the site already gets impressions for.',
8987
10146
  page_published_reddit: 'SEO page generated from a high-intent Reddit thread.',
8988
- page_published_top: 'SEO page generated for a top-of-funnel ranking opportunity.',
10147
+ page_published_top: 'SEO page generated for a top-of-funnel ranking opportunity (top_pages pipeline).',
8989
10148
  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.',
8990
10149
  page_published_roundup: 'Roundup or list-style SEO page (comparisons, best-of, alternatives).',
8991
- page_improved: 'Existing SEO page that was updated or rewritten to improve rankings.',
10150
+ 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.',
10151
+ page_improved: 'Existing SEO page that was updated or rewritten to improve rankings (seo_page_improvements table).',
8992
10152
  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.',
8993
10153
  resurrected: 'Previously archived or unavailable item brought back into rotation (e.g., a removed post restored after reappearing).',
8994
10154
  };
@@ -9000,7 +10160,10 @@ const ACTIVITY_CAMPAIGN_ORGANIC = '(organic)';
9000
10160
  let _activitySeen = new Set();
9001
10161
  let _activityFirstLoad = true;
9002
10162
  // Activity-tab filters/sort/search are persisted across reloads.
9003
- let _activityTypeFilter = saLoadSet('sa.activity.typeFilter.v2', EVENT_TYPES);
10163
+ // Bumped v2 -> v3 on 2026-05-16 when page_published_serp was split into
10164
+ // page_published_twitter + page_published_misc; old saved Sets did not
10165
+ // include the new types, so they'd silently disappear from the feed.
10166
+ let _activityTypeFilter = saLoadSet('sa.activity.typeFilter.v3', EVENT_TYPES);
9004
10167
  let _activityPlatformFilter = saLoadSet('sa.activity.platformFilter.v1', ACTIVITY_PLATFORMS);
9005
10168
  let _activityProjectFilter = saLoadSet('sa.activity.projectFilter.v1', []);
9006
10169
  let _activityKnownProjects = saLoad('sa.activity.knownProjects.v1', []);
@@ -9098,7 +10261,7 @@ function buildActivityFilters() {
9098
10261
  var added;
9099
10262
  if (_activityTypeFilter.has(t)) { _activityTypeFilter.delete(t); el.classList.remove('active'); added = false; }
9100
10263
  else { _activityTypeFilter.add(t); el.classList.add('active'); added = true; }
9101
- saSaveSet('sa.activity.typeFilter.v2', _activityTypeFilter);
10264
+ saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
9102
10265
  try { window.posthog && window.posthog.capture('filter_toggle', { table: 'activity', dimension: 'type', value: t, action: added ? 'add' : 'remove' }); } catch (er) {}
9103
10266
  _activityPage = 0;
9104
10267
  renderActivity(_lastActivityEvents || []);
@@ -9151,11 +10314,11 @@ function buildActivityFilters() {
9151
10314
  if (a === 'type-all') {
9152
10315
  _activityTypeFilter = new Set(EVENT_TYPES);
9153
10316
  tEl.querySelectorAll('[data-type]').forEach(c => c.classList.add('active'));
9154
- saSaveSet('sa.activity.typeFilter.v2', _activityTypeFilter);
10317
+ saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
9155
10318
  } else if (a === 'type-none') {
9156
10319
  _activityTypeFilter = new Set();
9157
10320
  tEl.querySelectorAll('[data-type]').forEach(c => c.classList.remove('active'));
9158
- saSaveSet('sa.activity.typeFilter.v2', _activityTypeFilter);
10321
+ saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
9159
10322
  } else if (a === 'platform-all') {
9160
10323
  _activityPlatformFilter = new Set(ACTIVITY_PLATFORMS);
9161
10324
  pEl.querySelectorAll('[data-platform]').forEach(c => c.classList.add('active'));
@@ -9257,19 +10420,21 @@ function fmtCost(c) {
9257
10420
  //
9258
10421
  // Args (no backticks anywhere; this whole helper sits inside the dashboard
9259
10422
  // HTML template literal, see feedback_server_js_template_regex memory):
9260
- // displayed value rendered in the cell. Already prefers SDK, falls
9261
- // back to estimate. Source of truth for the text.
10423
+ // displayed value rendered in the cell. Total = COALESCE(orch,
10424
+ // estimate) + subagent. Source of truth for the text.
9262
10425
  // orchestrator native SDK orchestrator cost (claude_sessions.
9263
10426
  // orchestrator_cost_usd, captured from streamRes.
9264
10427
  // total_cost_usd). Authoritative for orchestrator billing
9265
10428
  // but EXCLUDES Task subagent costs (anthropics/claude-code
9266
- // issue #43945).
9267
- // estimated manual transcript-derived estimate using local pricing
9268
- // tables (claude_sessions.total_cost_usd, written by
10429
+ // issue #43945). Subagent is now folded in via the 4th arg.
10430
+ // estimated manual transcript-derived estimate of orchestrator turns
10431
+ // only (claude_sessions.total_cost_usd, written by
9269
10432
  // log_claude_session.py).
9270
- function fmtCostCell(displayed, orchestrator, estimated) {
9271
- const text = fmtCost(displayed);
9272
- if (text === '') return '';
10433
+ // subagent Task/Agent subagent cost from sidechain transcripts +
10434
+ // sibling subagents/*.jsonl files (claude_sessions.
10435
+ // subagent_cost_usd). Added on top of the orch/estimate
10436
+ // lane to form the displayed total.
10437
+ function fmtCostCell(displayed, orchestrator, estimated, subagent) {
9273
10438
  const fmtLane = (v) => {
9274
10439
  if (v == null) return 'n/a';
9275
10440
  const n = Number(v);
@@ -9278,13 +10443,23 @@ function fmtCostCell(displayed, orchestrator, estimated) {
9278
10443
  if (n < 0.01) return '$' + n.toFixed(4);
9279
10444
  return '$' + n.toFixed(4);
9280
10445
  };
10446
+ // SDK-only mode (2026-05-15): the displayed value is orchestrator_cost_usd
10447
+ // alone. When that's NULL we render "n/a" — not $0, since the session DID
10448
+ // spend money but the wrapper didn't capture --orchestrator-cost-usd.
10449
+ // Estimate and subagent are diagnostic-only (computed from local pricing
10450
+ // table; not actual billing data).
10451
+ const hasOrch = orchestrator != null && Number.isFinite(Number(orchestrator));
10452
+ const text = hasOrch
10453
+ ? fmtCost(Number(orchestrator))
10454
+ : '<span style="color:var(--text-very-faint);">n/a</span>';
9281
10455
  const lines = [
9282
- 'Orchestrator (SDK): ' + fmtLane(orchestrator),
9283
- 'Estimated (transcript): ' + fmtLane(estimated),
10456
+ '**Cost (SDK orchestrator):** ' + fmtLane(orchestrator),
9284
10457
  '',
9285
- '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.',
10458
+ '**Diagnostic-only** (not actual billing)',
10459
+ '• Transcript estimate: ' + fmtLane(estimated),
10460
+ '• Subagent (est): ' + fmtLane(subagent),
9286
10461
  '',
9287
- '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.',
10462
+ '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.',
9288
10463
  ];
9289
10464
  const tip = lines.join('\\n');
9290
10465
  return '<span data-tooltip="' + escapeHtml(tip) +
@@ -9581,22 +10756,35 @@ function currentStatsProject() {
9581
10756
  const row = document.getElementById('style-stats-project-pills');
9582
10757
  return (row && row.dataset.selected) || 'all';
9583
10758
  }
9584
- function reloadStatsTabSections() {
9585
- loadActivityStats();
9586
- loadCohortStats();
9587
- loadStyleStats();
10759
+ // Sets body.sa-stats-busy while the batch of stats fetches kicked off by a
10760
+ // single filter change is in flight. CSS uses the class to disable the four
10761
+ // pill rows (status window, stats window, style-stats platform/project) so
10762
+ // users can't queue overlapping reloads across rapid pill clicks.
10763
+ async function reloadStatsTabSections() {
10764
+ document.body.classList.add('sa-stats-busy');
10765
+ const pending = [
10766
+ loadActivityStats(),
10767
+ loadCohortStats(),
10768
+ loadStyleStats(),
10769
+ ];
9588
10770
  // daily-metrics chart now lives on its own Trends tab with its own filter
9589
10771
  // bar; intentionally NOT reloaded on stats-tab window/platform/project
9590
10772
  // of the filter bar.
9591
10773
  const funnelEl = document.getElementById('funnel-stats');
9592
10774
  if (funnelEl && funnelEl.open) {
9593
- if (_lastFunnelPayload) renderFunnelStats(_lastFunnelPayload);
9594
- else loadFunnelStats(true);
10775
+ // Always refetch when a filter pill changes — the cached payload was
10776
+ // built against the *previous* platform/window and rendering it would
10777
+ // contradict the engagement-style table above it. loadFunnelStats's
10778
+ // own (loadKey) check still keeps redundant fetches off when nothing
10779
+ // actually changed.
10780
+ pending.push(loadFunnelStats(true));
9595
10781
  }
9596
10782
  const dmEl = document.getElementById('dm-stats');
9597
- if (dmEl && dmEl.open) loadDmStats(true);
10783
+ if (dmEl && dmEl.open) pending.push(loadDmStats(true));
9598
10784
  const sqEl = document.getElementById('search-queries-stats');
9599
- if (sqEl && sqEl.open) loadSearchQueriesStats(true);
10785
+ if (sqEl && sqEl.open) pending.push(loadSearchQueriesStats(true));
10786
+ try { await Promise.allSettled(pending); }
10787
+ finally { document.body.classList.remove('sa-stats-busy'); }
9600
10788
  }
9601
10789
  function syncStatsHeadings() {
9602
10790
  const win = currentStatsWindow();
@@ -9641,7 +10829,63 @@ function renderActivityStats(payload) {
9641
10829
  grandTotal += n;
9642
10830
  });
9643
10831
  if (totalEl) totalEl.textContent = grandTotal + ' events in ' + currentStatsWindow().labelLong;
9644
- grid.innerHTML = EVENT_TYPES.map(t => {
10832
+ // Fold all page-generation subtypes into a single synthetic 'pages_generated'
10833
+ // card so the grid stops looking like six near-empty SEO cells. The breakdown
10834
+ // row inside the card lists each pipeline + count chip; the hover tooltip on
10835
+ // each chip shows the long description for that pipeline. Card-level "i"
10836
+ // icon shows the umbrella tooltip.
10837
+ const pagesBucket = { total: 0, subtypes: {} };
10838
+ PAGE_GEN_EVENT_TYPES.forEach(t => {
10839
+ const b = byType[t];
10840
+ if (!b) return;
10841
+ pagesBucket.total += b.total;
10842
+ if (b.total > 0) pagesBucket.subtypes[t] = b.total;
10843
+ });
10844
+ const PAGE_GEN_CHIP_LABELS = {
10845
+ page_published_twitter: 'twitter',
10846
+ page_published_gsc: 'gsc',
10847
+ page_published_reddit: 'reddit',
10848
+ page_published_top: 'top',
10849
+ page_published_top_post: 'top post',
10850
+ page_published_roundup: 'roundup',
10851
+ page_published_misc: 'misc',
10852
+ page_improved: 'improved',
10853
+ };
10854
+ // Render order: keep PAGE_GEN_EVENT_TYPES order, then drop the non-page tail
10855
+ // cards (page_expired, resurrected) at the end. Suppress individual page_*
10856
+ // cards since we render the umbrella card instead.
10857
+ const renderOrder = [];
10858
+ EVENT_TYPES.forEach(t => {
10859
+ if (PAGE_GEN_EVENT_TYPES.includes(t)) return; // folded into umbrella
10860
+ renderOrder.push(t);
10861
+ });
10862
+ // Insert the umbrella where the first page card used to live (after dm_reply_sent).
10863
+ const dmIdx = renderOrder.indexOf('dm_reply_sent');
10864
+ if (dmIdx >= 0) renderOrder.splice(dmIdx + 1, 0, '__pages_generated__');
10865
+ else renderOrder.push('__pages_generated__');
10866
+
10867
+ grid.innerHTML = renderOrder.map(t => {
10868
+ if (t === '__pages_generated__') {
10869
+ const total = pagesBucket.total;
10870
+ const subtypes = Object.keys(pagesBucket.subtypes).sort((a, b) => pagesBucket.subtypes[b] - pagesBucket.subtypes[a]);
10871
+ const chips = subtypes.length
10872
+ ? subtypes.map(st => {
10873
+ const lab = PAGE_GEN_CHIP_LABELS[st] || st.replace(/^page_published_/, '');
10874
+ const desc = EVENT_DESCRIPTIONS[st] || '';
10875
+ const titleAttr = desc ? ' data-tooltip="' + escapeHtml(desc) + '"' : '';
10876
+ return '<span class="stat-plat"' + titleAttr + '><span class="stat-plat-text">' + escapeHtml(lab) + '</span><span class="stat-plat-count">' + pagesBucket.subtypes[st] + '</span></span>';
10877
+ }).join('')
10878
+ : '<span style="color:var(--text-very-faint);">\u2014</span>';
10879
+ const umbrellaDesc = EVENT_DESCRIPTIONS.pages_generated || '';
10880
+ const infoIcon = '<span class="stat-card-info" data-tooltip="' + escapeHtml(umbrellaDesc) + '" aria-label="' + escapeHtml(umbrellaDesc) + '">i</span>';
10881
+ return '<div class="stat-card ev-pages-generated' + (total === 0 ? ' zero' : '') + '">' +
10882
+ '<div class="stat-card-head">' +
10883
+ '<span class="stat-card-label">pages generated' + infoIcon + '</span>' +
10884
+ '<span class="stat-card-count">' + total + '</span>' +
10885
+ '</div>' +
10886
+ '<div class="stat-card-breakdown">' + chips + '</div>' +
10887
+ '</div>';
10888
+ }
9645
10889
  const bucket = byType[t];
9646
10890
  const total = bucket.total;
9647
10891
  const plats = Object.keys(bucket.platforms).sort((a, b) => bucket.platforms[b] - bucket.platforms[a]);
@@ -9668,6 +10912,13 @@ function renderActivityStats(payload) {
9668
10912
  }
9669
10913
 
9670
10914
  async function loadActivityStats() {
10915
+ // Immediate visual feedback on filter change. Without this the previously
10916
+ // rendered grid sits frozen until the 9-way UNION returns; on a cold cache
10917
+ // miss that's a couple seconds with zero indication anything is happening.
10918
+ const grid = document.getElementById('stats-grid');
10919
+ const totalEl = document.getElementById('stats-total');
10920
+ if (grid) grid.classList.add('is-loading');
10921
+ if (totalEl) totalEl.textContent = 'loading…';
9671
10922
  try {
9672
10923
  const hours = currentStatsWindow().hours;
9673
10924
  const plat = currentStatsPlatform();
@@ -9678,7 +10929,9 @@ async function loadActivityStats() {
9678
10929
  const res = await fetch('/api/activity/stats?' + params.join('&'));
9679
10930
  const data = await res.json();
9680
10931
  renderActivityStats(data);
9681
- } catch {}
10932
+ } catch {} finally {
10933
+ if (grid) grid.classList.remove('is-loading');
10934
+ }
9682
10935
  }
9683
10936
 
9684
10937
  // Combined daily-metrics line chart (Trends tab). Fetches 4 endpoints (2
@@ -9698,6 +10951,14 @@ async function loadActivityStats() {
9698
10951
  // to a capture day; expect those lines to sit at 0 until at least two
9699
10952
  // consecutive days of snapshots have accumulated per post.
9700
10953
  let DAILY_METRICS = [
10954
+ // Output volume: posts we made per day, split by type. 'threads' counts
10955
+ // posts where we authored the thread itself; 'comments_made' counts posts
10956
+ // where we engaged on someone else's thread. Both come from the same
10957
+ // /api/posts/per-day endpoint (server returns threads_made + comments_made
10958
+ // alongside posts_made). 'comments_made' is intentionally distinct from
10959
+ // the 'comments' pill below, which counts comments EARNED on our posts.
10960
+ { id: 'threads', label: 'Threads', color: '#a855f7', endpoint: '/api/posts/per-day', valueKey: 'threads_made', platformAware: true },
10961
+ { id: 'comments_made', label: 'Comments Made', color: '#d946ef', endpoint: '/api/posts/per-day', valueKey: 'comments_made', platformAware: true },
9701
10962
  { id: 'views', label: 'Views', color: '#6366f1', endpoint: '/api/views/per-day', valueKey: 'views_gained', platformAware: true },
9702
10963
  { id: 'upvotes', label: 'Upvotes', color: '#f97316', endpoint: '/api/upvotes/per-day', valueKey: 'upvotes_gained', platformAware: true },
9703
10964
  { id: 'comments', label: 'Comments', color: '#14b8a6', endpoint: '/api/comments/per-day', valueKey: 'comments_gained', platformAware: true },
@@ -9844,6 +11105,13 @@ function renderDailyMetrics() {
9844
11105
  if (active.has(id)) active.delete(id); else active.add(id);
9845
11106
  _saveDailyMetricsActive();
9846
11107
  renderDailyMetrics();
11108
+ // Mirror the legend change across all per-project mini charts so they
11109
+ // stay in sync with the main chart's metric selection. No fetch.
11110
+ try {
11111
+ if (_perProjectState && _perProjectState.loadedKey) {
11112
+ _perProjectGetProjects().forEach(p => _renderPerProjectChart(p, 'daily'));
11113
+ }
11114
+ } catch {}
9847
11115
  });
9848
11116
  });
9849
11117
 
@@ -10167,6 +11435,13 @@ function renderRatioMetrics() {
10167
11435
  if (active.has(id)) active.delete(id); else active.add(id);
10168
11436
  _saveRatioMetricsActive();
10169
11437
  renderRatioMetrics();
11438
+ // Same pattern as the daily-metrics legend: mirror the toggle across
11439
+ // the per-project ratio mini-charts (no fetch, just SVG rebuild).
11440
+ try {
11441
+ if (_perProjectState && _perProjectState.loadedKey) {
11442
+ _perProjectGetProjects().forEach(p => _renderPerProjectChart(p, 'ratio'));
11443
+ }
11444
+ } catch {}
10170
11445
  });
10171
11446
  });
10172
11447
  const visible = RATIO_METRICS.filter(r => active.has(r.id));
@@ -10315,6 +11590,508 @@ function renderRatioMetrics() {
10315
11590
  }
10316
11591
  }
10317
11592
 
11593
+ // ============================================================================
11594
+ // Per-project breakdown for the Trends tab. Renders the same Daily Metrics
11595
+ // and Engagement Ratios charts (sharing the user's legend selection) once per
11596
+ // project, stacked vertically. Lazy-loaded on first details open; cached by
11597
+ // granularity+platform (so toggling either filter triggers a refetch but the
11598
+ // legend pills just re-render from the cached series). Always shows ALL
11599
+ // projects regardless of the Trends project filter — the breakdown is the
11600
+ // place to compare projects against each other.
11601
+ // ============================================================================
11602
+ const _perProjectState = {
11603
+ loadedKey: null, // 'daily|all' style cache key
11604
+ series: {}, // { project: { metricId: { dayISO: value } } }
11605
+ days: [], // axis (daily or weekly buckets)
11606
+ failed: {}, // { project: [{key, timedOut}] }
11607
+ loading: false, // a fetch cycle is in flight
11608
+ };
11609
+
11610
+ function _perProjectGetProjects() {
11611
+ const pills = document.querySelectorAll('#trends-project-pills .style-stats-pill[data-value]');
11612
+ return Array.from(pills)
11613
+ .map(p => p.getAttribute('data-value'))
11614
+ .filter(v => v && v !== 'all');
11615
+ }
11616
+
11617
+ function _perProjectCacheKey() {
11618
+ return currentTrendsGranularity() + '|' + currentTrendsPlatform();
11619
+ }
11620
+
11621
+ // True when at least one of the per-project details elements is open.
11622
+ // Used to decide whether to (re)load on filter change; if both are closed
11623
+ // the data is irrelevant until the user opens one.
11624
+ function _perProjectIsAnyOpen() {
11625
+ const a = document.getElementById('daily-metrics-per-project');
11626
+ const b = document.getElementById('ratio-metrics-per-project');
11627
+ return !!((a && a.open) || (b && b.open));
11628
+ }
11629
+
11630
+ // Maybe-invalidate: only clears state when the cache key (granularity +
11631
+ // platform) actually changed. Project filter is irrelevant to per-project
11632
+ // breakdown (it always shows all projects), so a project-only change is a
11633
+ // no-op. Called from loadDailyMetrics on every refilter; cheap when no
11634
+ // section is open and no key change happened.
11635
+ function _perProjectInvalidate() {
11636
+ const want = _perProjectCacheKey();
11637
+ const changed = _perProjectState.loadedKey && _perProjectState.loadedKey !== want;
11638
+ if (changed) {
11639
+ _perProjectState.loadedKey = null;
11640
+ _perProjectState.series = {};
11641
+ _perProjectState.failed = {};
11642
+ }
11643
+ if (_perProjectIsAnyOpen()) {
11644
+ _perProjectLoadIfNeeded();
11645
+ } else if (changed) {
11646
+ // Reset both row containers to a hint state so when the user opens the
11647
+ // section next, they don't see stale data.
11648
+ ['daily-metrics-per-project-rows', 'ratio-metrics-per-project-rows'].forEach(id => {
11649
+ const el = document.getElementById(id);
11650
+ if (el) el.innerHTML = '<div class="views-chart-empty">Click to load per-project charts.</div>';
11651
+ });
11652
+ ['daily-metrics-per-project-status', 'ratio-metrics-per-project-status'].forEach(id => {
11653
+ const el = document.getElementById(id);
11654
+ if (el) el.textContent = '';
11655
+ });
11656
+ }
11657
+ }
11658
+
11659
+ // Single fetch helper: 7 best-effort endpoints (8 in admin mode w/ cost) for
11660
+ // one project, mirroring the shape of loadDailyMetrics. Returns a flat
11661
+ // { series: { metricId: { day: value } }, failed: [...] } record.
11662
+ async function _perProjectFetchOne(project, gran, platform, fetchDays, dailyAxis) {
11663
+ // 30s per endpoint. /api/funnel/per-day shells out to PostHog HogQL which
11664
+ // routinely takes 10-25s on a cold cache; 9s was killing 19 of 23 projects'
11665
+ // funnel fetch on first page-load and rendering them as silent zeros.
11666
+ // Pair this with the funnel pre-warmer (launchd com.m13v.social-funnel-prewarm)
11667
+ // which keeps the 5-min server cache hot so most fetches return instantly.
11668
+ const FETCH_TIMEOUT_MS = 30000;
11669
+ const fetchOne = async (url) => {
11670
+ const ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
11671
+ const timer = ctl ? setTimeout(() => { try { ctl.abort(); } catch {} }, FETCH_TIMEOUT_MS) : null;
11672
+ try {
11673
+ const res = await fetch(url, ctl ? { signal: ctl.signal } : undefined);
11674
+ if (!res.ok) return { rows: [], failed: true, status: res.status };
11675
+ const data = await res.json();
11676
+ return { rows: data.rows || [], failed: false };
11677
+ } catch (e) {
11678
+ const msg = String(e && e.message || e);
11679
+ const timedOut = msg.includes('aborted') || msg.includes('Timeout') || msg.includes('timed out');
11680
+ return { rows: [], failed: true, timedOut };
11681
+ } finally {
11682
+ if (timer) clearTimeout(timer);
11683
+ }
11684
+ };
11685
+ // Force the project filter; platform stays inherited from the Trends bar.
11686
+ const platformAwareParams = ['days=' + fetchDays, 'project=' + encodeURIComponent(project)];
11687
+ if (platform && platform !== 'all') platformAwareParams.push('platform=' + encodeURIComponent(platform));
11688
+ const projectOnlyParams = ['days=' + fetchDays, 'project=' + encodeURIComponent(project)];
11689
+ const qsAware = platformAwareParams.join('&');
11690
+ const qsProj = projectOnlyParams.join('&');
11691
+ const costAvail = window.SA_IS_ADMIN !== false;
11692
+ const [views, upvotes, comments, clicks, bookings, funnel, cost, posts] = await Promise.all([
11693
+ fetchOne('/api/views/per-day?' + qsAware),
11694
+ fetchOne('/api/upvotes/per-day?' + qsAware),
11695
+ fetchOne('/api/comments/per-day?' + qsAware),
11696
+ fetchOne('/api/clicks/per-day?' + qsAware),
11697
+ fetchOne('/api/bookings/per-day?' + qsProj),
11698
+ fetchOne('/api/funnel/per-day?' + qsProj),
11699
+ costAvail ? fetchOne('/api/cost/per-day?' + qsAware) : Promise.resolve({ rows: [], failed: false }),
11700
+ fetchOne('/api/posts/per-day?' + qsAware),
11701
+ ]);
11702
+ const series = {};
11703
+ const intoSeries = (id, rows, key) => {
11704
+ const map = {};
11705
+ rows.forEach(r => { if (r && r.day) map[r.day] = Number(r[key]) || 0; });
11706
+ series[id] = map;
11707
+ };
11708
+ intoSeries('views', views.rows, 'views_gained');
11709
+ intoSeries('upvotes', upvotes.rows, 'upvotes_gained');
11710
+ intoSeries('comments', comments.rows, 'comments_gained');
11711
+ intoSeries('clicks', clicks.rows, 'clicks_gained');
11712
+ intoSeries('bookings', bookings.rows, 'bookings_gained');
11713
+ intoSeries('cost', cost.rows, 'cost_usd');
11714
+ intoSeries('posts', posts.rows, 'posts_made');
11715
+ intoSeries('threads', posts.rows, 'threads_made');
11716
+ intoSeries('comments_made', posts.rows, 'comments_made');
11717
+ DAILY_METRICS.filter(m => m.funnel).forEach(m => {
11718
+ intoSeries(m.id, funnel.rows, m.valueKey);
11719
+ });
11720
+ // Apply weekly bucketing if needed (same shape as loadDailyMetrics).
11721
+ let outSeries = series;
11722
+ if (gran === 'weekly') {
11723
+ const aggregated = {};
11724
+ DAILY_METRICS.forEach(m => {
11725
+ const bucketed = _bucketWeekly(dailyAxis, series[m.id] || {});
11726
+ aggregated[m.id] = bucketed.weeklyMap;
11727
+ });
11728
+ outSeries = aggregated;
11729
+ }
11730
+ const fetchResults = { views, upvotes, comments, clicks, bookings, funnel, cost, posts };
11731
+ const failed = Object.keys(fetchResults)
11732
+ .filter(k => fetchResults[k].failed)
11733
+ .map(k => ({ key: k, timedOut: !!fetchResults[k].timedOut }));
11734
+ return { series: outSeries, failed };
11735
+ }
11736
+
11737
+ // Run an async fn over items with bounded concurrency. Used so we don't
11738
+ // fire 8 endpoints * 25 projects = 200 simultaneous requests at PostHog.
11739
+ async function _perProjectRunConcurrency(items, limit, fn) {
11740
+ const queue = items.slice();
11741
+ const workers = [];
11742
+ for (let i = 0; i < Math.min(limit, queue.length); i++) {
11743
+ workers.push((async () => {
11744
+ while (queue.length) {
11745
+ const item = queue.shift();
11746
+ if (item == null) break;
11747
+ try { await fn(item); } catch {}
11748
+ }
11749
+ })());
11750
+ }
11751
+ await Promise.all(workers);
11752
+ }
11753
+
11754
+ // Build a compact column chart SVG. Same visual model as renderDailyMetrics
11755
+ // but smaller (130px tall, no value labels above bars, X labels at first/
11756
+ // middle/last only). Returns { svgHtml, plot } where plot exposes the
11757
+ // padding + dimensions for hover wiring.
11758
+ function _buildMiniChartSvg(opts) {
11759
+ const days = opts.days || [];
11760
+ const visible = opts.visible || [];
11761
+ const valueOf = opts.valueOf; // (metric, day) => number|null
11762
+ const isRatio = !!opts.isRatio;
11763
+ const formatter = opts.formatter; // (metric, value) => string
11764
+ const seriesPeak = {};
11765
+ visible.forEach(m => {
11766
+ let p = 0;
11767
+ days.forEach(d => {
11768
+ const v = valueOf(m, d);
11769
+ if (v == null || !isFinite(v)) return;
11770
+ p = Math.max(p, v);
11771
+ });
11772
+ seriesPeak[m.id] = p;
11773
+ });
11774
+ const isSingle = visible.length === 1;
11775
+ const singleMetric = isSingle ? visible[0] : null;
11776
+ const yMax = isSingle ? _niceMax(Math.max(isRatio ? 0.01 : 0, seriesPeak[visible[0].id] || 0)) : 0;
11777
+ const width = 960;
11778
+ const height = 130;
11779
+ const padL = isSingle ? 44 : 12;
11780
+ const padR = 12, padT = 10, padB = 16;
11781
+ const plotW = width - padL - padR;
11782
+ const plotH = height - padT - padB;
11783
+ const groupWidth = days.length > 0 ? plotW / days.length : 0;
11784
+ const groupGap = Math.max(1, groupWidth * 0.18);
11785
+ const barCount = visible.length;
11786
+ const barSpacing = barCount > 1 ? 1 : 0;
11787
+ const innerWidth = Math.max(1, groupWidth - groupGap);
11788
+ const barWidth = Math.max(1, (innerWidth - barSpacing * (barCount - 1)) / barCount);
11789
+ const yOfFor = (m) => {
11790
+ const peak = isSingle ? yMax : (seriesPeak[m.id] || 0);
11791
+ return v => padT + plotH - (peak > 0 ? (v / peak) * plotH : 0);
11792
+ };
11793
+ const xCenter = i => padL + groupWidth * i + groupWidth / 2;
11794
+ const yGrid = [0, 0.5, 1].map(t => {
11795
+ const y = padT + plotH - t * plotH;
11796
+ let label = '';
11797
+ if (isSingle) {
11798
+ const v = yMax * t;
11799
+ label = '<text class="axis-text" x="' + (padL - 6) + '" y="' + (y + 3) + '" text-anchor="end">' + escapeHtml(formatter(singleMetric, v)) + '</text>';
11800
+ }
11801
+ return '<line class="gridline" x1="' + padL + '" x2="' + (width - padR) + '" y1="' + y + '" y2="' + y + '"/>' + label;
11802
+ }).join('');
11803
+ const xLabelIdxs = days.length <= 1
11804
+ ? [0]
11805
+ : [0, Math.floor(days.length / 2), days.length - 1];
11806
+ const xLabels = Array.from(new Set(xLabelIdxs)).map(i => {
11807
+ return '<text class="axis-text" x="' + xCenter(i) + '" y="' + (height - 4) + '" text-anchor="middle">' + escapeHtml(_fmtDay(days[i])) + '</text>';
11808
+ }).join('');
11809
+ const bars = days.map((d, i) => {
11810
+ const groupX = padL + groupWidth * i + groupGap / 2;
11811
+ return visible.map((m, mi) => {
11812
+ const v = valueOf(m, d);
11813
+ if (v == null || !isFinite(v)) return '';
11814
+ if (!isRatio && v === 0) return ''; // skip zero bars for cleanliness
11815
+ const x = groupX + (barWidth + barSpacing) * mi;
11816
+ const yOf = yOfFor(m);
11817
+ const y = yOf(v);
11818
+ const h = Math.max(0, padT + plotH - y);
11819
+ return '<rect class="series-bar" data-day="' + escapeHtml(d) + '" data-metric="' + escapeHtml(m.id) + '" x="' + x.toFixed(2) + '" y="' + y.toFixed(2) + '" width="' + barWidth.toFixed(2) + '" height="' + h.toFixed(2) + '" fill="' + m.color + '"/>';
11820
+ }).join('');
11821
+ }).join('');
11822
+ const svgHtml =
11823
+ '<svg viewBox="0 0 ' + width + ' ' + height + '" preserveAspectRatio="none" role="img">' +
11824
+ yGrid + xLabels + bars +
11825
+ '<line class="hover-line" x1="0" y1="' + padT + '" x2="0" y2="' + (padT + plotH) + '"/>' +
11826
+ '<rect class="pp-hover-rect" x="' + padL + '" y="' + padT + '" width="' + plotW + '" height="' + plotH + '" fill="transparent"/>' +
11827
+ '</svg>' +
11828
+ '<div class="pp-tooltip"></div>';
11829
+ return { svgHtml, plot: { width, height, padL, padR, padT, padB, plotW, plotH, groupWidth, xCenter } };
11830
+ }
11831
+
11832
+ // Attach hover behavior to a mini chart inside chartEl. visible / valueOf /
11833
+ // formatter match the build call so the tooltip shows the same values.
11834
+ function _wireMiniChartHover(chartEl, plot, opts) {
11835
+ const days = opts.days;
11836
+ const visible = opts.visible;
11837
+ const valueOf = opts.valueOf;
11838
+ const formatter = opts.formatter;
11839
+ const emptyLabelFor = opts.emptyLabelFor; // (metric) => string | null
11840
+ const rect = chartEl.querySelector('.pp-hover-rect');
11841
+ const hoverLine = chartEl.querySelector('.hover-line');
11842
+ const tip = chartEl.querySelector('.pp-tooltip');
11843
+ if (!rect || !hoverLine || !tip) return;
11844
+ const show = e => {
11845
+ const svgEl = chartEl.querySelector('svg');
11846
+ const box = svgEl.getBoundingClientRect();
11847
+ const relX = e.clientX - box.left;
11848
+ const scale = plot.width / box.width;
11849
+ const svgX = relX * scale;
11850
+ const idxRaw = (svgX - plot.padL) / (plot.groupWidth || 1);
11851
+ const idx = Math.max(0, Math.min(days.length - 1, Math.floor(idxRaw)));
11852
+ const snapX = plot.xCenter(idx);
11853
+ hoverLine.setAttribute('x1', snapX);
11854
+ hoverLine.setAttribute('x2', snapX);
11855
+ hoverLine.style.opacity = '1';
11856
+ const day = days[idx];
11857
+ const rows = visible.map(m => {
11858
+ const v = valueOf(m, day);
11859
+ let display;
11860
+ if (v == null || !isFinite(v)) {
11861
+ display = (emptyLabelFor ? emptyLabelFor(m) : '\u2014');
11862
+ } else {
11863
+ display = formatter(m, v);
11864
+ }
11865
+ return '<div class="tt-row"><span class="swatch" style="background:' + m.color + ';"></span>' +
11866
+ '<span>' + escapeHtml(m.label) + '</span>' +
11867
+ '<span class="val">' + escapeHtml(display) + '</span></div>';
11868
+ }).join('');
11869
+ tip.innerHTML = '<div class="tt-day">' + escapeHtml(_fmtDay(day)) + '</div>' + rows;
11870
+ const cssX = snapX / scale;
11871
+ tip.style.transform = 'none';
11872
+ tip.style.visibility = 'hidden';
11873
+ tip.style.opacity = '1';
11874
+ const tipW = tip.offsetWidth;
11875
+ const tipH = tip.offsetHeight;
11876
+ const cw = chartEl.clientWidth;
11877
+ const margin = 4;
11878
+ const gap = 10;
11879
+ const chartBox = chartEl.getBoundingClientRect();
11880
+ const svgOffsetLeft = box.left - chartBox.left;
11881
+ const cursorPx = svgOffsetLeft + cssX;
11882
+ let leftPx = cursorPx - tipW / 2;
11883
+ if (leftPx + tipW > cw - margin) {
11884
+ leftPx = cursorPx - tipW - gap;
11885
+ if (leftPx < margin) leftPx = Math.max(margin, cw - margin - tipW);
11886
+ } else if (leftPx < margin) {
11887
+ leftPx = cursorPx + gap;
11888
+ if (leftPx + tipW > cw - margin) leftPx = Math.max(margin, cw - margin - tipW);
11889
+ }
11890
+ let topPx = plot.padT - 6 - tipH;
11891
+ if (topPx < margin) topPx = plot.padT + 12;
11892
+ tip.style.left = leftPx + 'px';
11893
+ tip.style.top = topPx + 'px';
11894
+ tip.style.visibility = '';
11895
+ };
11896
+ const hide = () => { hoverLine.style.opacity = '0'; tip.style.opacity = '0'; };
11897
+ rect.addEventListener('mousemove', show);
11898
+ rect.addEventListener('mouseleave', hide);
11899
+ }
11900
+
11901
+ // Render one mini chart for a single project in either 'daily' or 'ratio' scope.
11902
+ function _renderPerProjectChart(project, scope) {
11903
+ const rowsEl = document.getElementById(scope === 'daily' ? 'daily-metrics-per-project-rows' : 'ratio-metrics-per-project-rows');
11904
+ if (!rowsEl) return;
11905
+ const rowEl = rowsEl.querySelector('.per-project-row[data-project="' + (window.CSS && CSS.escape ? CSS.escape(project) : project) + '"]');
11906
+ if (!rowEl) return;
11907
+ const chartEl = rowEl.querySelector('.per-project-chart');
11908
+ if (!chartEl) return;
11909
+ const projectSeries = _perProjectState.series[project];
11910
+ if (!projectSeries) {
11911
+ chartEl.innerHTML = '<div class="pp-empty">Loading\u2026</div>';
11912
+ rowEl.classList.add('pp-loading');
11913
+ return;
11914
+ }
11915
+ rowEl.classList.remove('pp-loading');
11916
+ const days = _perProjectState.days;
11917
+ // Map each metric id -> the endpoint key its data comes from in _perProjectFetchOne.
11918
+ // Used below to detect when ALL visible metrics are powered by a failed endpoint
11919
+ // (e.g. funnel timed out for this project), so we can render an explicit "failed
11920
+ // to load" hint instead of silently drawing flat zeros that look like real data.
11921
+ const failedKeys = new Set((_perProjectState.failed[project] || []).map(f => f.key));
11922
+ const _sourceKeyForMetric = (m) => {
11923
+ if (m.funnel) return 'funnel';
11924
+ if (m.id === 'posts' || m.id === 'threads' || m.id === 'comments_made') return 'posts';
11925
+ return m.id; // views / upvotes / comments / clicks / bookings / cost match by id
11926
+ };
11927
+ if (scope === 'daily') {
11928
+ const active = _loadDailyMetricsActive();
11929
+ const visible = DAILY_METRICS.filter(m => active.has(m.id));
11930
+ if (!visible.length) {
11931
+ chartEl.innerHTML = '<div class="pp-empty">No metrics selected.</div>';
11932
+ return;
11933
+ }
11934
+ // If every visible metric's source endpoint failed for this project,
11935
+ // show a clear failure hint instead of silent zeros.
11936
+ if (visible.every(m => failedKeys.has(_sourceKeyForMetric(m)))) {
11937
+ const failKeys = Array.from(new Set(visible.map(m => _sourceKeyForMetric(m))));
11938
+ const timedOut = (_perProjectState.failed[project] || []).some(f => f.timedOut && failKeys.includes(f.key));
11939
+ chartEl.innerHTML = '<div class="pp-empty" style="color:#dc2626;">Failed to load (' + (timedOut ? 'timed out' : 'error') + ': ' + escapeHtml(failKeys.join(', ')) + ')</div>';
11940
+ return;
11941
+ }
11942
+ const valueOf = (m, d) => Number((projectSeries[m.id] || {})[d]) || 0;
11943
+ const formatter = (m, v) => _fmtForMetric(m, v);
11944
+ const { svgHtml, plot } = _buildMiniChartSvg({ days, visible, valueOf, isRatio: false, formatter });
11945
+ chartEl.innerHTML = svgHtml;
11946
+ _wireMiniChartHover(chartEl, plot, { days, visible, valueOf, formatter });
11947
+ } else {
11948
+ const active = _loadRatioMetricsActive();
11949
+ const visible = RATIO_METRICS.filter(r => active.has(r.id));
11950
+ if (!visible.length) {
11951
+ chartEl.innerHTML = '<div class="pp-empty">No ratios selected.</div>';
11952
+ return;
11953
+ }
11954
+ // Build per-ratio per-day series from this project's numerator/denominator.
11955
+ const ratioCache = {};
11956
+ visible.forEach(r => {
11957
+ const numByDay = projectSeries[r.numerator] || {};
11958
+ const denByDay = projectSeries[r.denominator] || {};
11959
+ const scale = (typeof r.scaleFactor === 'number') ? r.scaleFactor : 100;
11960
+ const map = {};
11961
+ days.forEach(d => {
11962
+ const num = Number(numByDay[d]) || 0;
11963
+ const den = Number(denByDay[d]) || 0;
11964
+ map[d] = (den > 0) ? (num / den * scale) : null;
11965
+ });
11966
+ ratioCache[r.id] = map;
11967
+ });
11968
+ const valueOf = (r, d) => ratioCache[r.id][d];
11969
+ const formatter = (r, v) => _fmtForRatio(r, v);
11970
+ const emptyLabelFor = (r) => (r.denominator === 'pageviews') ? 'no visitors'
11971
+ : (r.denominator === 'posts') ? 'no posts'
11972
+ : 'no views';
11973
+ const { svgHtml, plot } = _buildMiniChartSvg({ days, visible, valueOf, isRatio: true, formatter });
11974
+ chartEl.innerHTML = svgHtml;
11975
+ _wireMiniChartHover(chartEl, plot, { days, visible, valueOf, formatter, emptyLabelFor });
11976
+ }
11977
+ }
11978
+
11979
+ // Render BOTH scopes (daily + ratio) for one project. Cheap; only the open
11980
+ // section will be visually scrolled into view, but rendering both keeps the
11981
+ // state symmetric.
11982
+ function _renderPerProjectBoth(project) {
11983
+ _renderPerProjectChart(project, 'daily');
11984
+ _renderPerProjectChart(project, 'ratio');
11985
+ }
11986
+
11987
+ // Re-render every per-project row for both scopes. Used after legend pill
11988
+ // toggles (no fetch, just SVG rebuild) and after a fresh load completes.
11989
+ function _renderPerProjectAll() {
11990
+ const projects = _perProjectGetProjects();
11991
+ projects.forEach(p => _renderPerProjectBoth(p));
11992
+ }
11993
+
11994
+ // Build (or rebuild) the row skeleton in both containers. One row per project
11995
+ // with a project label and an empty .per-project-chart waiting for render.
11996
+ function _renderPerProjectSkeleton() {
11997
+ const projects = _perProjectGetProjects();
11998
+ ['daily-metrics-per-project-rows', 'ratio-metrics-per-project-rows'].forEach(id => {
11999
+ const el = document.getElementById(id);
12000
+ if (!el) return;
12001
+ if (!projects.length) {
12002
+ el.innerHTML = '<div class="views-chart-empty">No projects.</div>';
12003
+ return;
12004
+ }
12005
+ el.innerHTML = projects.map(p => (
12006
+ '<div class="per-project-row pp-loading" data-project="' + escapeHtml(p) + '">' +
12007
+ '<div class="per-project-label">' + escapeHtml(p) + '</div>' +
12008
+ '<div class="per-project-chart"><div class="pp-empty">Loading\u2026</div></div>' +
12009
+ '</div>'
12010
+ )).join('');
12011
+ });
12012
+ }
12013
+
12014
+ function _setPerProjectStatus(text) {
12015
+ ['daily-metrics-per-project-status', 'ratio-metrics-per-project-status'].forEach(id => {
12016
+ const el = document.getElementById(id);
12017
+ if (el) el.textContent = text || '';
12018
+ });
12019
+ }
12020
+
12021
+ // Lazy-load entry point. Triggered by details toggle and by filter changes
12022
+ // while at least one details is open. No-op if already loading or already
12023
+ // loaded for the current (granularity, platform) key.
12024
+ async function _perProjectLoadIfNeeded() {
12025
+ const want = _perProjectCacheKey();
12026
+ if (_perProjectState.loadedKey === want) {
12027
+ _renderPerProjectSkeleton();
12028
+ _renderPerProjectAll();
12029
+ return;
12030
+ }
12031
+ if (_perProjectState.loading) return;
12032
+ _perProjectState.loading = true;
12033
+ _perProjectState.loadedKey = want;
12034
+ _perProjectState.series = {};
12035
+ _perProjectState.failed = {};
12036
+ const gran = currentTrendsGranularity();
12037
+ const platform = currentTrendsPlatform();
12038
+ const fetchDays = gran === 'weekly' ? DAILY_METRICS_DAYS_WEEKLY : DAILY_METRICS_DAYS_DAILY;
12039
+ const today = new Date();
12040
+ const dailyAxis = [];
12041
+ for (let i = fetchDays - 1; i >= 0; i--) {
12042
+ const d = new Date(today);
12043
+ d.setDate(today.getDate() - i);
12044
+ dailyAxis.push(d.toISOString().slice(0, 10));
12045
+ }
12046
+ // Axis stored on state matches the granularity (weekly buckets or daily).
12047
+ if (gran === 'weekly') {
12048
+ const { weekKeys } = _bucketWeekly(dailyAxis, {});
12049
+ _perProjectState.days = weekKeys;
12050
+ } else {
12051
+ _perProjectState.days = dailyAxis;
12052
+ }
12053
+ _renderPerProjectSkeleton();
12054
+ const projects = _perProjectGetProjects();
12055
+ let done = 0;
12056
+ _setPerProjectStatus('0 / ' + projects.length);
12057
+ try {
12058
+ // 2 concurrent projects = 16 simultaneous endpoint calls (8 per project).
12059
+ // 3 was overloading the dashboard's pg pool and PostHog rate limits on a
12060
+ // cold cache; 2 keeps the per-project load slow-but-steady and lets the
12061
+ // pg pool (now max:25) actually drain between waves.
12062
+ await _perProjectRunConcurrency(projects, 2, async (project) => {
12063
+ // Bail if filters changed mid-load.
12064
+ if (_perProjectState.loadedKey !== want) return;
12065
+ const { series, failed } = await _perProjectFetchOne(project, gran, platform, fetchDays, dailyAxis);
12066
+ if (_perProjectState.loadedKey !== want) return;
12067
+ _perProjectState.series[project] = series;
12068
+ _perProjectState.failed[project] = failed;
12069
+ _renderPerProjectBoth(project);
12070
+ done += 1;
12071
+ _setPerProjectStatus(done + ' / ' + projects.length);
12072
+ });
12073
+ } finally {
12074
+ _perProjectState.loading = false;
12075
+ }
12076
+ // Final status: how many had any failed endpoint?
12077
+ const failedCount = Object.values(_perProjectState.failed).filter(arr => arr && arr.length).length;
12078
+ _setPerProjectStatus(projects.length + ' / ' + projects.length + (failedCount ? ' · ' + failedCount + ' partial' : ''));
12079
+ }
12080
+
12081
+ // Wire details toggle on first DOMContentLoaded. Idempotent so it survives
12082
+ // any partial reloads. Hook is delayed via setTimeout to make sure the
12083
+ // elements exist before we attach.
12084
+ function _wirePerProjectToggles() {
12085
+ ['daily-metrics-per-project', 'ratio-metrics-per-project'].forEach(id => {
12086
+ const el = document.getElementById(id);
12087
+ if (!el || el.dataset.wired === '1') return;
12088
+ el.dataset.wired = '1';
12089
+ el.addEventListener('toggle', () => {
12090
+ if (el.open) _perProjectLoadIfNeeded();
12091
+ });
12092
+ });
12093
+ }
12094
+
10318
12095
  // Trends-tab filter state. Selection is read off the trends pill rows by
10319
12096
  // data-selected; granularity drives the day count and axis bucketing.
10320
12097
  function currentTrendsPlatform() {
@@ -10390,13 +12167,14 @@ async function loadDailyMetrics() {
10390
12167
  // or any transient 5xx) renders the affected series as flat zeros instead
10391
12168
  // of killing the whole chart. The "Unable to load daily metrics" fallback
10392
12169
  // below now only triggers when literally every fetch failed.
10393
- // Per-endpoint timeout. /api/funnel/per-day shells out to PostHog +
10394
- // Postgres and at days=91 (weekly window) it can hang well past 15s,
10395
- // which previously froze the entire chart because Promise.all waited on
10396
- // the slowest of 5. Cap each fetch at ~9s so a slow endpoint degrades
10397
- // gracefully (renders as flat zeros with the "rendered N of 5" note in
10398
- // the status pill) instead of leaving a permanent "Loading…" placeholder.
10399
- const FETCH_TIMEOUT_MS = 9000;
12170
+ // Per-endpoint timeout. /api/funnel/per-day shells out to PostHog HogQL
12171
+ // and at days=91 (weekly window) routinely takes 15-25s on a cold cache.
12172
+ // 9s was too tight: on a fresh page load the funnel endpoint timed out
12173
+ // and the top chart's Get Started / Visitors / Email Signups / Schedule
12174
+ // Clicks series all rendered as flat zeros. Bumped to 30s; paired with
12175
+ // the funnel pre-warmer (launchd com.m13v.social-funnel-prewarm) which
12176
+ // keeps the 5-min server cache hot so most fetches still return fast.
12177
+ const FETCH_TIMEOUT_MS = 30000;
10400
12178
  const fetchOne = async (url) => {
10401
12179
  const ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
10402
12180
  const timer = ctl ? setTimeout(() => { try { ctl.abort(); } catch {} }, FETCH_TIMEOUT_MS) : null;
@@ -10452,6 +12230,8 @@ async function loadDailyMetrics() {
10452
12230
  intoSeries('bookings', bookings.rows, 'bookings_gained');
10453
12231
  intoSeries('cost', cost.rows, 'cost_usd');
10454
12232
  intoSeries('posts', posts.rows, 'posts_made');
12233
+ intoSeries('threads', posts.rows, 'threads_made');
12234
+ intoSeries('comments_made', posts.rows, 'comments_made');
10455
12235
  DAILY_METRICS.filter(m => m.funnel).forEach(m => {
10456
12236
  intoSeries(m.id, funnel.rows, m.valueKey);
10457
12237
  });
@@ -10480,6 +12260,11 @@ async function loadDailyMetrics() {
10480
12260
  .map(k => ({ key: k, timedOut: !!fetchResults[k].timedOut }));
10481
12261
  renderDailyMetrics();
10482
12262
  renderRatioMetrics();
12263
+ // Propagate filter changes to the per-project breakdown. Cheap no-op
12264
+ // when neither section is open and the cache key (granularity+platform)
12265
+ // hasn't actually changed; otherwise it kicks off a fresh per-project
12266
+ // fetch in the background.
12267
+ try { _perProjectInvalidate(); } catch {}
10483
12268
  } catch (e) {
10484
12269
  if (chartEl) chartEl.innerHTML = '<div class="views-chart-empty">Unable to load daily metrics (' + escapeHtml(String(e.message || e)) + ').</div>';
10485
12270
  }
@@ -10777,7 +12562,7 @@ let _styleStatsTableState = { sortField: 'score', sortDir: 'desc', filters: {} }
10777
12562
  // OS-level title-attribute delay.
10778
12563
  const STYLE_STATS_HELP = {
10779
12564
  style: 'Engagement tone Claude used to draft this first-touch comment/post (slug from scripts/engagement_styles.py). The A/B testing system uses these stats to decide which tones to imitate next. Note: a row in the posts table = our FIRST-TOUCH engagement on a thread, not (usually) an original thread we authored. Reddit/Moltbook/GitHub = our top-level comment on someone else’s thread; X = our reply; LinkedIn = our comment. Subsequent back-and-forth replies live in a separate replies pipeline and are not counted here.',
10780
- score: 'Per-post quality signal computed on engagement that landed on OUR comment/post (replies to it, upvotes on it), not on the underlying third-party thread. Formula: (comments * 3 + upvotes_discounted) / posts. upvotes_discounted subtracts the OP self-upvote on Reddit and Moltbook so those platforms compare fairly with X/LinkedIn. Views are deliberately excluded so low-volume styles compare fairly with high-volume ones. Same signal the feedback report uses.',
12565
+ score: 'Per-post quality signal computed on engagement that landed on OUR comment/post (clicks on our short link, replies to it, upvotes on it), not on the underlying third-party thread. Formula: (post_clicks * 10 + comments * 3 + upvotes_discounted) / posts. Click weight ×10 because a real human click outvalues 10 likes of vibes (matches top_performers.SCORE_SQL and the engagement_styles.py picker). upvotes_discounted subtracts the OP self-upvote on Reddit and Moltbook so those platforms compare fairly with X/LinkedIn. Views are deliberately excluded so low-volume styles compare fairly with high-volume ones.',
10781
12566
  posts: 'Count of first-touch comments/posts published in this style during the selected window. (Reddit comments on others’ threads, X replies, LinkedIn comments, etc. The rare run-reddit-threads.sh original-thread rows are also counted.)',
10782
12567
  upvotes: 'Sum of upvotes/likes received by OUR comment (or our thread, in the rare original-thread case). Net of the Reddit/Moltbook OP self-upvote (both platforms auto-apply +1 on every post; we strip it so a brand-new post starts at 0, not 1). Other platforms (X, LinkedIn, GitHub) pass through unchanged. Per-post average shown in parentheses.',
10783
12568
  comments: 'Sum of replies received by OUR comment (or comments under our thread). Per-post average in parentheses. Tracked in the posts.comments_count column, independent of the separate replies pipeline that records replies WE author.',
@@ -10824,17 +12609,31 @@ function formatStyleCell(name, metaMap) {
10824
12609
  const m = (metaMap && metaMap[name]) || null;
10825
12610
  if (!m || name === '(none)') return safeName;
10826
12611
  const lines = [];
10827
- if (m.description) lines.push(m.description);
10828
- if (m.note) lines.push('Note: ' + m.note);
10829
- if (m.why_existing_didnt_fit) lines.push('Why invented: ' + m.why_existing_didnt_fit);
12612
+ // Bold the style name as the tooltip header.
12613
+ lines.push('**' + name + '**');
12614
+ if (m.description) {
12615
+ lines.push('');
12616
+ lines.push(m.description);
12617
+ }
12618
+ if (m.note) {
12619
+ lines.push('');
12620
+ lines.push('**Note:** ' + m.note);
12621
+ }
12622
+ if (m.why_existing_didnt_fit) {
12623
+ lines.push('');
12624
+ lines.push('**Why invented:** ' + m.why_existing_didnt_fit);
12625
+ }
10830
12626
  const status = m.status || 'active';
10831
12627
  const provenance = [];
10832
12628
  if (status && status !== 'active') provenance.push('status=' + status);
10833
12629
  if (m.invented_at) provenance.push('invented ' + String(m.invented_at).slice(0, 10));
10834
12630
  if (m.first_post_platform) provenance.push('first on ' + m.first_post_platform);
10835
12631
  if (m.promoted_at) provenance.push('promoted ' + String(m.promoted_at).slice(0, 10));
10836
- if (provenance.length) lines.push(provenance.join(' · '));
10837
- if (!lines.length) return safeName;
12632
+ if (provenance.length) {
12633
+ lines.push('');
12634
+ lines.push(provenance.join(' · '));
12635
+ }
12636
+ if (lines.length <= 1) return safeName;
10838
12637
  const tip = lines.join('\\n');
10839
12638
  return '<span data-tooltip="' + escapeHtml(tip) + '" style="cursor: help; border-bottom: 1px dotted var(--text-muted);">' + safeName + '</span>';
10840
12639
  }
@@ -10897,15 +12696,17 @@ function renderStyleStats(payload, meta) {
10897
12696
  const per = total / denom;
10898
12697
  return fmt(total) + ' <span style="color:var(--text-muted);">(' + perPostStr(per) + ')</span>';
10899
12698
  };
10900
- // Per-post score matches top_performers.SCORE_SQL (comments*3 + upvotes, Reddit
10901
- // self-upvote discounted at SQL layer). Views deliberately excluded so this is
10902
- // the same signal Claude uses for imitation; comparing by per-post keeps low-
10903
- // volume styles on equal footing with high-volume ones.
12699
+ // Per-post score matches top_performers.SCORE_SQL and engagement_styles.py
12700
+ // picker (clicks*10 + comments*3 + upvotes, Reddit self-upvote discounted
12701
+ // at SQL layer). Click weight ×10: one real human click outvalues 10 likes
12702
+ // of vibes when ranking styles. Views deliberately excluded. Per-post avg
12703
+ // keeps low-volume styles on equal footing with high-volume ones.
10904
12704
  const normalized = rows.map(r => {
10905
12705
  const posts = Number(r.posts) || 0;
10906
12706
  const comments = Number(r.comments) || 0;
10907
12707
  const upvotesDiscounted = Number(r.upvotes_discounted) || 0;
10908
- const score = posts > 0 ? (comments * 3 + upvotesDiscounted) / posts : 0;
12708
+ const postClicks = Number(r.post_clicks) || 0;
12709
+ const score = posts > 0 ? (postClicks * 10 + comments * 3 + upvotesDiscounted) / posts : 0;
10909
12710
  return {
10910
12711
  style: r.style || '(none)',
10911
12712
  posts,
@@ -10913,7 +12714,7 @@ function renderStyleStats(payload, meta) {
10913
12714
  upvotes: Number(r.upvotes) || 0,
10914
12715
  comments,
10915
12716
  views: Number(r.views) || 0,
10916
- post_clicks: Number(r.post_clicks) || 0,
12717
+ post_clicks: postClicks,
10917
12718
  recommendations: Number(r.recommendations) || 0,
10918
12719
  score,
10919
12720
  };
@@ -10971,6 +12772,10 @@ function getStyleMeta() {
10971
12772
  }
10972
12773
 
10973
12774
  async function loadStyleStats() {
12775
+ const body = document.getElementById('style-stats-body');
12776
+ const totalEl = document.getElementById('style-stats-total');
12777
+ if (body) body.classList.add('is-loading');
12778
+ if (totalEl) totalEl.textContent = 'loading…';
10974
12779
  try {
10975
12780
  const platformRow = document.getElementById('style-stats-platform-pills');
10976
12781
  const projectRow = document.getElementById('style-stats-project-pills');
@@ -10985,17 +12790,21 @@ async function loadStyleStats() {
10985
12790
  getStyleMeta(),
10986
12791
  ]);
10987
12792
  renderStyleStats(statsRes, meta);
10988
- } catch {}
12793
+ } catch {} finally {
12794
+ if (body) body.classList.remove('is-loading');
12795
+ }
10989
12796
  }
10990
12797
 
10991
12798
  // Score-cohort distribution. Buckets posts in the trailing window into
10992
- // 4 cohorts (dead/low/mid/high) by composite score (comments*3 + upvotes,
10993
- // minus 1 on Reddit/Moltbook to strip the OP self-upvote). Mirrors top_performers.
12799
+ // 4 cohorts (dead/low/mid/high) by composite score (clicks*10 + comments*3 +
12800
+ // upvotes, minus 1 on Reddit/Moltbook to strip the OP self-upvote). Mirrors
12801
+ // top_performers.SCORE_SQL and the engagement_styles.py picker. Bands
12802
+ // rescaled to absorb the click ×10 weight (a single click already adds 10).
10994
12803
  const COHORT_DEFS = [
10995
- { key: 'dead', label: 'Dead', scoreLabel: '0', blurb: 'No discussion, no upvotes beyond the OP self-upvote (Reddit/Moltbook). Skip imitating.' },
10996
- { key: 'low', label: 'Low', scoreLabel: '1\u20134', blurb: 'A handful of upvotes OR a single comment. Faint signal, below the per-platform meaningful-engagement floor.' },
10997
- { key: 'mid', label: 'Mid', scoreLabel: '5\u201314', blurb: 'Real but modest reaction. Roughly 1\u20134 comments, or 5\u201314 upvotes, or a mix.' },
10998
- { key: 'high', label: 'High', scoreLabel: '15+', blurb: 'Posts that actually sparked discussion. Imitate these.' },
12804
+ { key: 'dead', label: 'Dead', scoreLabel: '0', blurb: 'No discussion, no upvotes beyond the OP self-upvote (Reddit/Moltbook), no clicks. Skip imitating.' },
12805
+ { key: 'low', label: 'Low', scoreLabel: '1\u20139', blurb: 'A handful of upvotes OR a couple of comments, but no real clicks. Faint signal.' },
12806
+ { key: 'mid', label: 'Mid', scoreLabel: '10\u201329', blurb: 'A single real click (×10), OR strong discussion (3+ comments), OR 10\u201329 upvotes. Worth imitating.' },
12807
+ { key: 'high', label: 'High', scoreLabel: '30+', blurb: 'Multiple clicks plus discussion, or a viral comment thread. Imitate these.' },
10999
12808
  ];
11000
12809
  const COHORT_COLORS = { dead: '#9ca3af', low: '#60a5fa', mid: '#22c55e', high: '#a855f7' };
11001
12810
 
@@ -11035,7 +12844,7 @@ function renderCohortStats(payload) {
11035
12844
  const headers = [
11036
12845
  { label: 'Cohort', tip: 'Bucket name. Tooltip on each row explains what that range means.' },
11037
12846
  { label: 'Posts', tip: 'Number of posts in the bucket (and share of total in the current window/platform/project filter).' },
11038
- { label: 'Score', tip: 'Composite score = comments \u00D7 3 + upvotes (Reddit/Moltbook subtract 1 to strip OP self-upvote). Range and average within this cohort.' },
12847
+ { label: 'Score', tip: 'Composite score = clicks \u00D7 10 + comments \u00D7 3 + upvotes (Reddit/Moltbook subtract 1 to strip OP self-upvote). Click weight \u00D710 matches top_performers.SCORE_SQL and the engagement_styles.py picker. Range and average within this cohort.' },
11039
12848
  { label: 'Upvotes', tip: 'Upvote/like/reaction count range (min\u2013max) and average within this cohort. Raw, before the Reddit/Moltbook self-upvote discount.' },
11040
12849
  { label: 'Comments', tip: 'Reply/comment count range (min\u2013max) and average within this cohort.' },
11041
12850
  { label: 'Views', tip: 'View count range (min\u2013max) and average within this cohort. Excludes Moltbook and GitHub since those platforms do not expose view counts.' },
@@ -11088,6 +12897,10 @@ function renderCohortStats(payload) {
11088
12897
  }
11089
12898
 
11090
12899
  async function loadCohortStats() {
12900
+ const body = document.getElementById('cohort-stats-body');
12901
+ const totalEl = document.getElementById('cohort-stats-total');
12902
+ if (body) body.classList.add('is-loading');
12903
+ if (totalEl) totalEl.textContent = 'loading…';
11091
12904
  try {
11092
12905
  const platformRow = document.getElementById('style-stats-platform-pills');
11093
12906
  const projectRow = document.getElementById('style-stats-project-pills');
@@ -11101,8 +12914,9 @@ async function loadCohortStats() {
11101
12914
  const data = await res.json();
11102
12915
  renderCohortStats(data);
11103
12916
  } catch (e) {
11104
- const body = document.getElementById('cohort-stats-body');
11105
12917
  if (body) body.innerHTML = '<div class="style-stats-empty">Failed to load cohort stats.</div>';
12918
+ } finally {
12919
+ if (body) body.classList.remove('is-loading');
11106
12920
  }
11107
12921
  }
11108
12922
 
@@ -11577,7 +13391,7 @@ function renderSearchQueriesStats(payload) {
11577
13391
  formatter: (v, row) => {
11578
13392
  const n = Number(v) || 0;
11579
13393
  if (row.serp_quality_avg != null) {
11580
- const tip = 'avg SERP quality (LinkedIn only): ' + row.serp_quality_avg.toFixed(1) + '/10';
13394
+ const tip = '**Avg SERP quality** (LinkedIn only): **' + row.serp_quality_avg.toFixed(1) + '/10**';
11581
13395
  return '<span data-tooltip="' + escapeHtml(tip) + '">' + fmt(n) + '</span>';
11582
13396
  }
11583
13397
  return fmt(n);
@@ -11585,7 +13399,7 @@ function renderSearchQueriesStats(payload) {
11585
13399
  { key: 'dud_rate', label: 'Dud %', type: 'numeric', align: 'right', widthPct: 5,
11586
13400
  formatter: (v, row) => {
11587
13401
  const n = Number(v) || 0;
11588
- const tip = (row.dud_attempts || 0) + ' of ' + (row.attempts || 0) + ' attempts returned 0';
13402
+ const tip = '**' + (row.dud_attempts || 0) + '** of **' + (row.attempts || 0) + '** attempts returned 0';
11589
13403
  const color = n >= 0.5 ? 'var(--danger,#dc2626)' : (n >= 0.25 ? 'var(--warn,#d97706)' : 'var(--text-secondary)');
11590
13404
  return '<span data-tooltip="' + escapeHtml(tip) + '" style="color:' + color + ';font-variant-numeric:tabular-nums;">' + pct(n) + '</span>';
11591
13405
  } },
@@ -11598,13 +13412,15 @@ function renderSearchQueriesStats(payload) {
11598
13412
  { key: 'avg_engagement', label: 'Avg Eng', type: 'numeric', align: 'right', widthPct: 4,
11599
13413
  formatter: v => {
11600
13414
  if (v == null) return '<span style="color:var(--text-faint);">\u2014</span>';
11601
- const tip = 'comments\u00D73 + upvotes on our reply (same formula as top_performers.py)';
13415
+ const tip = '**Formula:** comments\u00D73 + upvotes on our reply' + String.fromCharCode(10) +
13416
+ '(same as top_performers.py)';
11602
13417
  return '<span data-tooltip="' + escapeHtml(tip) + '" style="font-variant-numeric:tabular-nums;">' + fmt1(v) + '</span>';
11603
13418
  } },
11604
13419
  { key: 'avg_views', label: 'Avg Views', type: 'numeric', align: 'right', widthPct: 4,
11605
13420
  formatter: v => {
11606
13421
  if (v == null) return '<span data-tooltip="LinkedIn does not expose comment views" style="color:var(--text-faint);">\u2014</span>';
11607
- const tip = 'avg view count on our Twitter reply (raw, not weighted into Avg Eng)';
13422
+ const tip = '**Avg view count** on our Twitter reply' + String.fromCharCode(10) +
13423
+ '(raw, not weighted into Avg Eng)';
11608
13424
  return '<span data-tooltip="' + escapeHtml(tip) + '" style="font-variant-numeric:tabular-nums;">' + fmt(Math.round(v)) + '</span>';
11609
13425
  } },
11610
13426
  { key: 'last_run', label: 'Last Run', type: 'numeric', align: 'right', widthPct: 4,
@@ -11637,34 +13453,40 @@ function renderCostStats(payload) {
11637
13453
  const byType = {};
11638
13454
  rows.forEach(r => { byType[r.type] = r; });
11639
13455
  const merged = COST_TYPE_ORDER.map(t => {
11640
- const r = byType[t] || { count: 0, total_cost_usd: 0, total_cost_usd_orchestrator: 0, total_cost_usd_estimated: 0 };
13456
+ 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 };
11641
13457
  const count = Number(r.count) || 0;
11642
- const total = Number(r.total_cost_usd) || 0;
13458
+ // SDK-only: total = orchestrator. Estimate/subagent kept for diagnostic
13459
+ // tooltips. /api/cost/stats SQL emits total_cost_usd = SUM(per_row_cost)
13460
+ // which itself is SUM(orchestrator/rows), so it equals total_cost_usd_
13461
+ // orchestrator by construction in SDK-only mode.
11643
13462
  const totalOrch = r.total_cost_usd_orchestrator != null ? Number(r.total_cost_usd_orchestrator) : null;
11644
13463
  const totalEst = r.total_cost_usd_estimated != null ? Number(r.total_cost_usd_estimated) : null;
13464
+ const totalSub = r.total_cost_usd_subagent != null ? Number(r.total_cost_usd_subagent) : null;
13465
+ const total = totalOrch != null ? totalOrch : 0;
11645
13466
  return {
11646
13467
  type: t, label: COST_TYPE_LABELS[t], count: count,
11647
- total: total, totalOrch: totalOrch, totalEst: totalEst,
13468
+ total: total, totalOrch: totalOrch, totalEst: totalEst, totalSub: totalSub,
11648
13469
  avg: count > 0 ? total / count : 0,
11649
13470
  avgOrch: count > 0 && totalOrch != null ? totalOrch / count : null,
11650
13471
  avgEst: count > 0 && totalEst != null ? totalEst / count : null,
13472
+ avgSub: count > 0 && totalSub != null ? totalSub / count : null,
11651
13473
  };
11652
13474
  });
11653
13475
  const totalCount = merged.reduce(function (a, r) { return a + r.count; }, 0);
11654
13476
  const totalCost = merged.reduce(function (a, r) { return a + r.total; }, 0);
11655
13477
  const totalOrch = merged.reduce(function (a, r) { return a + (r.totalOrch || 0); }, 0);
11656
13478
  const totalEst = merged.reduce(function (a, r) { return a + (r.totalEst || 0); }, 0);
13479
+ const totalSub = merged.reduce(function (a, r) { return a + (r.totalSub || 0); }, 0);
11657
13480
  if (totalEl) {
11658
13481
  totalEl.textContent = '$' + totalCost.toFixed(2) + ' · ' + totalCount.toLocaleString() + ' activit' + (totalCount === 1 ? 'y' : 'ies');
11659
- // Tooltip on the header pill so users can see both lanes for the
11660
- // headline figure without expanding the table.
11661
13482
  const tipLines = [
11662
- 'Orchestrator (SDK): $' + totalOrch.toFixed(4),
11663
- 'Estimated (transcript): $' + totalEst.toFixed(4),
13483
+ '**Cost (SDK orchestrator):** $' + totalOrch.toFixed(4),
11664
13484
  '',
11665
- '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.',
13485
+ '**Diagnostic-only** (local pricing estimate, not actual billing)',
13486
+ '• Transcript estimate: $' + totalEst.toFixed(4),
13487
+ '• Subagent (est): $' + totalSub.toFixed(4),
11666
13488
  '',
11667
- 'Note: orchestrator cost EXCLUDES Task subagent spend (anthropics/claude-code #43945).',
13489
+ '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.',
11668
13490
  ];
11669
13491
  totalEl.setAttribute('data-tooltip', tipLines.join('\\n'));
11670
13492
  totalEl.style.cursor = 'help';
@@ -11677,56 +13499,110 @@ function renderCostStats(payload) {
11677
13499
  return '$' + n.toFixed(2);
11678
13500
  }
11679
13501
  function fmtCount(v) { return (Number(v) || 0).toLocaleString(); }
11680
- // Wraps a money cell in a span that exposes both cost lanes via tooltip.
11681
- function moneyCell(displayed, orch, est) {
13502
+ function moneyCell(displayed, orch, est, sub) {
11682
13503
  const tip = [
11683
- 'Orchestrator (SDK): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
11684
- 'Estimated (transcript): ' + (est != null ? fmtMoney(est) : 'n/a'),
13504
+ '**Cost (SDK orchestrator):** ' + (orch != null ? fmtMoney(orch) : 'n/a'),
11685
13505
  '',
11686
- 'Displayed value prefers SDK; falls back to transcript estimate. Subagent costs excluded (anthropics/claude-code #43945).',
13506
+ '**Diagnostic-only** (local pricing estimate)',
13507
+ '• Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
13508
+ '• Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
11687
13509
  ].join('\\n');
11688
13510
  return '<span data-tooltip="' + escapeHtml(tip) +
11689
13511
  '" style="cursor:help;border-bottom:1px dotted var(--text-muted);">' +
11690
13512
  fmtMoney(displayed) + '</span>';
11691
13513
  }
11692
13514
  const rowsHtml = merged.map(function (r) {
11693
- const totalCellHtml = moneyCell(r.total, r.totalOrch, r.totalEst);
13515
+ const totalCellHtml = moneyCell(r.total, r.totalOrch, r.totalEst, r.totalSub);
13516
+ const subCellHtml = r.totalSub != null && r.totalSub > 0 ? fmtMoney(r.totalSub) : '<span style="color:var(--text-very-faint);">$0</span>';
11694
13517
  const avgCellHtml = r.count > 0
11695
- ? moneyCell(r.avg, r.avgOrch, r.avgEst)
13518
+ ? moneyCell(r.avg, r.avgOrch, r.avgEst, r.avgSub)
11696
13519
  : '&mdash;';
11697
13520
  return '<tr>' +
11698
13521
  '<td>' + escapeHtml(r.label) + '</td>' +
11699
13522
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(r.count) + '</td>' +
11700
13523
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + totalCellHtml + '</td>' +
13524
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + subCellHtml + '</td>' +
11701
13525
  '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + avgCellHtml + '</td>' +
11702
13526
  '</tr>';
11703
13527
  }).join('');
11704
- const footerTotalHtml = moneyCell(totalCost, totalOrch, totalEst);
13528
+ const footerTotalHtml = moneyCell(totalCost, totalOrch, totalEst, totalSub);
13529
+ const footerSubHtml = totalSub > 0 ? fmtMoney(totalSub) : '<span style="color:var(--text-very-faint);">$0</span>';
11705
13530
  const footerAvgHtml = totalCount > 0
11706
13531
  ? moneyCell(totalCost / totalCount,
11707
13532
  totalOrch / totalCount,
11708
- totalEst / totalCount)
13533
+ totalEst / totalCount,
13534
+ totalSub / totalCount)
11709
13535
  : '&mdash;';
11710
13536
  const footerHtml =
11711
13537
  '<tr style="border-top:2px solid var(--border);font-weight:600;background:var(--bg-subtle);">' +
11712
13538
  '<td>Total</td>' +
11713
13539
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(totalCount) + '</td>' +
11714
13540
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerTotalHtml + '</td>' +
13541
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerSubHtml + '</td>' +
11715
13542
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerAvgHtml + '</td>' +
11716
13543
  '</tr>';
13544
+ // Per-phase (script) breakdown. Same window, separate query — gives the
13545
+ // operator an answer to "which phase of which pipeline is burning cash?"
13546
+ // independent of the activity-type rollup above. A row like
13547
+ // run-twitter-cycle-scan dominating the spend is the signal to investigate.
13548
+ const phases = (payload && payload.phases) || [];
13549
+ let phaseTableHtml = '';
13550
+ if (phases.length) {
13551
+ const phaseRowsHtml = phases.map(function (p) {
13552
+ const orch = p.total_cost_usd_orchestrator != null ? Number(p.total_cost_usd_orchestrator) : 0;
13553
+ const est = p.total_cost_usd_estimated != null ? Number(p.total_cost_usd_estimated) : null;
13554
+ const sub = p.total_cost_usd_subagent != null ? Number(p.total_cost_usd_subagent) : null;
13555
+ const sessions = Number(p.sessions) || 0;
13556
+ const withSdk = Number(p.sessions_with_sdk) || 0;
13557
+ const missing = Math.max(0, sessions - withSdk);
13558
+ // SDK-only: per-phase total = orchestrator sum. Phases with 0% SDK
13559
+ // coverage show $0 with a "(N/N missing)" hint so it's obvious the
13560
+ // wrapper needs patching.
13561
+ const totalCellInner = withSdk > 0
13562
+ ? moneyCell(orch, orch, est, sub)
13563
+ : '<span style="color:var(--text-very-faint);">n/a</span>';
13564
+ const coverageCell = missing === 0
13565
+ ? ('<span style="color:#15803d;">' + sessions + '/' + sessions + '</span>')
13566
+ : ('<span style="color:#b91c1c;" title="' + missing + ' sessions missing SDK cost (wrapper script needs --output-format json on claude call)">' +
13567
+ withSdk + '/' + sessions + '</span>');
13568
+ const perSession = withSdk > 0
13569
+ ? moneyCell(orch / withSdk, orch / withSdk, est != null ? est / sessions : null, sub != null ? sub / sessions : null)
13570
+ : '&mdash;';
13571
+ return '<tr>' +
13572
+ '<td style="font-family:ui-monospace,monospace;font-size:12px;">' + escapeHtml(p.phase || '(unknown)') + '</td>' +
13573
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(sessions) + '</td>' +
13574
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;font-size:11px;">' + coverageCell + '</td>' +
13575
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + totalCellInner + '</td>' +
13576
+ '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + perSession + '</td>' +
13577
+ '</tr>';
13578
+ }).join('');
13579
+ phaseTableHtml =
13580
+ '<div style="font-size:12px;font-weight:600;padding:12px 2px 4px;color:var(--text-secondary);">Cost per Phase (Claude session script)</div>' +
13581
+ '<table class="style-stats-table">' +
13582
+ '<thead><tr>' +
13583
+ '<th style="text-align:left;">Phase</th>' +
13584
+ '<th style="text-align:right;">Sessions</th>' +
13585
+ '<th style="text-align:right;" title="Sessions with SDK cost captured / total sessions. Red = wrapper needs patching.">SDK coverage</th>' +
13586
+ '<th style="text-align:right;">Cost (SDK)</th>' +
13587
+ '<th style="text-align:right;">Cost per Session</th>' +
13588
+ '</tr></thead>' +
13589
+ '<tbody>' + phaseRowsHtml + '</tbody>' +
13590
+ '</table>';
13591
+ }
11717
13592
  body.innerHTML =
11718
13593
  '<table class="style-stats-table">' +
11719
13594
  '<thead><tr>' +
11720
13595
  '<th style="text-align:left;">Type</th>' +
11721
13596
  '<th style="text-align:right;">Activities</th>' +
11722
13597
  '<th style="text-align:right;">Total Cost</th>' +
13598
+ '<th style="text-align:right;">Subagent</th>' +
11723
13599
  '<th style="text-align:right;">Cost per Activity</th>' +
11724
13600
  '</tr></thead>' +
11725
13601
  '<tbody>' + rowsHtml + footerHtml + '</tbody>' +
11726
13602
  '</table>' +
13603
+ phaseTableHtml +
11727
13604
  '<div style="font-size:11px;color:var(--text-muted);padding:8px 2px 2px;">' +
11728
- 'Cost is Claude session spend split evenly across the activity rows each session produced. ' +
11729
- 'Totals here exclude skipped replies, resurrected posts, DM replies, and mentions.' +
13605
+ '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.' +
11730
13606
  '</div>';
11731
13607
  }
11732
13608
 
@@ -11779,6 +13655,10 @@ let _topDmsTableState = { sortField: 'rank', sortDir: 'asc', filters: {} };
11779
13655
  let _topDmsLoaded = false;
11780
13656
  let _topDmsLoading = false;
11781
13657
  let _topDmsPayload = null;
13658
+ let _topLinksTableState = { sortField: 'real_clicks', sortDir: 'desc', filters: {} };
13659
+ let _topLinksLoaded = false;
13660
+ let _topLinksLoading = false;
13661
+ let _topLinksPayload = null;
11782
13662
  let _topDmDir = saLoad('sa.top.dmDir.v1', 'all');
11783
13663
  let _topDmInterest = saLoad('sa.top.dmInterest.v1', 'all');
11784
13664
  let _topDmMode = saLoad('sa.top.dmMode.v1', 'all');
@@ -12163,7 +14043,15 @@ function renderTopPosts(payload) {
12163
14043
  let clickLine = '';
12164
14044
  if (hasLink) {
12165
14045
  if (havePerClick) {
12166
- const tip = 'Humans / bots split from post_link_clicks (UA bot regex). Real clicks are the human number. Bots are Twitter card / LinkedIn unfurl / Slack preview prefetches. CTR = humans / views.';
14046
+ const tip =
14047
+ '**Clicks: humans / bots**' + String.fromCharCode(10) +
14048
+ String.fromCharCode(10) +
14049
+ 'Split from post_link_clicks (UA bot regex).' + String.fromCharCode(10) +
14050
+ String.fromCharCode(10) +
14051
+ '• **Real clicks** = the human number' + String.fromCharCode(10) +
14052
+ '• **Bots** = Twitter card / LinkedIn unfurl / Slack preview prefetches' + String.fromCharCode(10) +
14053
+ String.fromCharCode(10) +
14054
+ '**CTR** = humans / views';
12167
14055
  clickLine = '<span class="top-stats-bit" data-tooltip="' + escapeHtml(tip) + '">'
12168
14056
  + '<span class="top-stats-k">clicks</span>'
12169
14057
  + real
@@ -12171,7 +14059,14 @@ function renderTopPosts(payload) {
12171
14059
  + ' <span style="color:var(--text-muted);">/ ' + bots + '</span>'
12172
14060
  + '</span>';
12173
14061
  } else if (backfill > 0) {
12174
- const tip = 'Estimated from PostHog $pageview events with matching utm_content (real humans only, bots already filtered by PostHog). Pre 2026-05-07 row, no per-click log; backfilled by scripts/backfill_real_clicks.py. CTR = backfill / views.';
14062
+ const tip =
14063
+ '**Clicks (estimated)**' + String.fromCharCode(10) +
14064
+ String.fromCharCode(10) +
14065
+ 'Estimated from PostHog $pageview events with matching utm_content. Real humans only — bots already filtered by PostHog.' + String.fromCharCode(10) +
14066
+ String.fromCharCode(10) +
14067
+ 'Pre 2026-05-07 row, no per-click log; backfilled by scripts/backfill_real_clicks.py.' + String.fromCharCode(10) +
14068
+ String.fromCharCode(10) +
14069
+ '**CTR** = backfill / views';
12175
14070
  clickLine = '<span class="top-stats-bit" data-tooltip="' + escapeHtml(tip) + '">'
12176
14071
  + '<span class="top-stats-k">clicks</span>'
12177
14072
  + backfill
@@ -12179,7 +14074,14 @@ function renderTopPosts(payload) {
12179
14074
  + ' <span style="color:var(--text-muted);">(estimated)</span>'
12180
14075
  + '</span>';
12181
14076
  } else if (legacy > 0) {
12182
- const tip = 'Legacy click count (pre 2026-05-07). Twitter card / LinkedIn / Slack preview bots inflated this ~20x. New clicks split humans/bots in post_link_clicks. Destination domain has no PostHog so we cannot backfill the real number. CTR = legacy / views (also inflated).';
14077
+ const tip =
14078
+ '**Clicks (legacy)** — pre 2026-05-07' + String.fromCharCode(10) +
14079
+ String.fromCharCode(10) +
14080
+ 'Twitter card / LinkedIn / Slack preview bots inflated this ~20×.' + String.fromCharCode(10) +
14081
+ String.fromCharCode(10) +
14082
+ 'New clicks split humans/bots in post_link_clicks. Destination domain has no PostHog so we cannot backfill the real number.' + String.fromCharCode(10) +
14083
+ String.fromCharCode(10) +
14084
+ '**CTR** = legacy / views (also inflated)';
12183
14085
  clickLine = '<span class="top-stats-bit" data-tooltip="' + escapeHtml(tip) + '">'
12184
14086
  + '<span class="top-stats-k">clicks</span>'
12185
14087
  + legacy
@@ -12311,6 +14213,7 @@ const TOP_SUBTAB_HELP = {
12311
14213
  comments: 'Top comments your accounts have left under other people’s threads, ranked by reach and reactions.',
12312
14214
  pages: 'Top landing/SEO pages on your sites this period, ranked by pageviews.',
12313
14215
  dms: 'Direct message conversations with prospects, ranked by recent activity.',
14216
+ links: 'Destination URLs across all posts, ranked by clicks. One row per unique target URL (homepage vs audience pages vs SEO pages vs booking).',
12314
14217
  };
12315
14218
  function syncTopSubtabHelp() {
12316
14219
  const el = document.getElementById('top-subtab-help');
@@ -12363,12 +14266,14 @@ function initTopFilters() {
12363
14266
  saveDashboardWindow(_topWindow);
12364
14267
  if (_topSubtab === 'pages') loadTopPages(true);
12365
14268
  else if (_topSubtab === 'dms') { _topDmOffset = 0; loadTopDms(true); }
14269
+ else if (_topSubtab === 'links') loadTopLinks(true);
12366
14270
  else loadTopPosts(true);
12367
14271
  });
12368
14272
  wireTopPillRow('top-platform-pills', (v) => {
12369
14273
  _topPlatform = v || 'all';
12370
14274
  saSave('sa.top.platform.v1', _topPlatform);
12371
14275
  if (_topSubtab === 'dms') { _topDmOffset = 0; loadTopDms(true); }
14276
+ else if (_topSubtab === 'links') loadTopLinks(true);
12372
14277
  else loadTopPosts(true);
12373
14278
  });
12374
14279
  wireTopPillRow('top-project-pills', (v) => {
@@ -12376,6 +14281,7 @@ function initTopFilters() {
12376
14281
  saSave('sa.top.project.v1', _topProject);
12377
14282
  if (_topSubtab === 'pages') renderTopPagesFromCache();
12378
14283
  else if (_topSubtab === 'dms') { if (_topDmsPayload) renderTopDms(_topDmsPayload); }
14284
+ else if (_topSubtab === 'links') loadTopLinks(true);
12379
14285
  else loadTopPosts(true); // refetch so the SQL LIMIT applies AFTER project filter
12380
14286
  });
12381
14287
  wireTopPillRow('top-campaign-pills', (v) => {
@@ -12494,6 +14400,7 @@ function applyTopSubtabState(sub, loadData) {
12494
14400
  const pagesC = document.getElementById('top-pages-container');
12495
14401
  const pagesUnknownC = document.getElementById('top-pages-unknown-container');
12496
14402
  const dmsC = document.getElementById('top-dms-container');
14403
+ const linksC = document.getElementById('top-links-container');
12497
14404
  const platRowEl = document.getElementById('top-platform-pills');
12498
14405
  const projRowEl = document.getElementById('top-project-pills');
12499
14406
  const campRowEl = document.getElementById('top-campaign-pills');
@@ -12520,6 +14427,7 @@ function applyTopSubtabState(sub, loadData) {
12520
14427
  if (sub === 'pages') {
12521
14428
  if (postsC) postsC.classList.add('hidden');
12522
14429
  if (dmsC) dmsC.classList.add('hidden');
14430
+ if (linksC) linksC.classList.add('hidden');
12523
14431
  if (pagesC) pagesC.classList.remove('hidden');
12524
14432
  if (pagesUnknownC) pagesUnknownC.classList.remove('hidden');
12525
14433
  if (platRowEl) platRowEl.classList.add('hidden');
@@ -12533,6 +14441,7 @@ function applyTopSubtabState(sub, loadData) {
12533
14441
  if (postsC) postsC.classList.add('hidden');
12534
14442
  if (pagesC) pagesC.classList.add('hidden');
12535
14443
  if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
14444
+ if (linksC) linksC.classList.add('hidden');
12536
14445
  if (dmsC) dmsC.classList.remove('hidden');
12537
14446
  if (platRowEl) platRowEl.classList.remove('hidden');
12538
14447
  if (srcRowEl) srcRowEl.classList.add('hidden');
@@ -12546,10 +14455,29 @@ function applyTopSubtabState(sub, loadData) {
12546
14455
  searchElDm.value = _topDmSearch || '';
12547
14456
  }
12548
14457
  if (loadData) loadTopDms(true);
14458
+ } else if (sub === 'links') {
14459
+ if (postsC) postsC.classList.add('hidden');
14460
+ if (pagesC) pagesC.classList.add('hidden');
14461
+ if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
14462
+ if (dmsC) dmsC.classList.add('hidden');
14463
+ if (linksC) linksC.classList.remove('hidden');
14464
+ if (platRowEl) platRowEl.classList.remove('hidden');
14465
+ if (srcRowEl) srcRowEl.classList.add('hidden');
14466
+ if (campRowEl) campRowEl.classList.add('hidden');
14467
+ setDmRowsHidden(true);
14468
+ setLinkPillHidden(true);
14469
+ if (totalEl) totalEl.textContent = '';
14470
+ const searchElLinks = document.getElementById('top-search');
14471
+ if (searchElLinks) {
14472
+ searchElLinks.placeholder = 'Filter destinations by URL\u2026';
14473
+ searchElLinks.value = (_topLinksTableState && _topLinksTableState.globalQuery) || '';
14474
+ }
14475
+ if (loadData) loadTopLinks(true);
12549
14476
  } else {
12550
14477
  if (pagesC) pagesC.classList.add('hidden');
12551
14478
  if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
12552
14479
  if (dmsC) dmsC.classList.add('hidden');
14480
+ if (linksC) linksC.classList.add('hidden');
12553
14481
  if (postsC) postsC.classList.remove('hidden');
12554
14482
  if (platRowEl) platRowEl.classList.remove('hidden');
12555
14483
  if (srcRowEl) srcRowEl.classList.add('hidden');
@@ -12729,6 +14657,151 @@ async function loadTopPages(force) {
12729
14657
  }
12730
14658
  }
12731
14659
 
14660
+ // Render the colored kind badge for a destination row. The server's
14661
+ // /api/top/destinations endpoint classifies each row into one of seven kind
14662
+ // buckets (home / subpage / audience_page / seo / booking / github /
14663
+ // external / other) by reading config.json, so the client just looks up
14664
+ // the label and color here.
14665
+ function destinationKindBadge(kind, audienceAngle) {
14666
+ const map = {
14667
+ home: { cls: 'dest-kind-home', label: 'HOME' },
14668
+ subpage: { cls: 'dest-kind-subpage', label: 'SUBPAGE' },
14669
+ audience_page: { cls: 'dest-kind-subpage', label: 'AUDIENCE' },
14670
+ seo: { cls: 'dest-kind-seo', label: 'SEO' },
14671
+ booking: { cls: 'dest-kind-booking', label: 'BOOKING' },
14672
+ github: { cls: 'dest-kind-github', label: 'GITHUB' },
14673
+ external: { cls: 'dest-kind-external', label: 'EXT' },
14674
+ other: { cls: 'dest-kind-other', label: 'OTHER' },
14675
+ };
14676
+ const m = map[kind] || map.other;
14677
+ let label = m.label;
14678
+ if (kind === 'audience_page' && audienceAngle) {
14679
+ label = 'AUDIENCE: ' + audienceAngle;
14680
+ }
14681
+ return '<span class="dest-kind-badge ' + m.cls + '" title="' + escapeHtml(kind) + '">' + escapeHtml(label) + '</span>';
14682
+ }
14683
+
14684
+ async function loadTopLinks(force) {
14685
+ if (_topLinksLoading) return;
14686
+ const container = document.getElementById('top-links-container');
14687
+ if (!_topLinksPayload && container) {
14688
+ container.innerHTML = '<div class="style-stats-empty">Loading\u2026</div>';
14689
+ }
14690
+ _topLinksLoading = true;
14691
+ try {
14692
+ const params = new URLSearchParams();
14693
+ if (_topWindow) params.set('window', _topWindow);
14694
+ if (_topPlatform && _topPlatform !== 'all') params.set('platform', _topPlatform);
14695
+ if (_topProject && _topProject !== 'all') params.set('project', _topProject);
14696
+ const res = await fetch('/api/top/destinations?' + params.toString());
14697
+ const data = await res.json();
14698
+ _topLinksPayload = data;
14699
+ renderTopLinks(data);
14700
+ _topLinksLoaded = true;
14701
+ } catch (e) {
14702
+ if (container) container.innerHTML = '<div class="style-stats-empty">Failed to load.</div>';
14703
+ } finally {
14704
+ _topLinksLoading = false;
14705
+ }
14706
+ }
14707
+
14708
+ function renderTopLinks(payload) {
14709
+ const container = document.getElementById('top-links-container');
14710
+ if (!container) return;
14711
+ const totalEl = document.getElementById('top-total');
14712
+ const fmt = n => (Number(n) || 0).toLocaleString();
14713
+ const dests = Array.isArray(payload && payload.destinations) ? payload.destinations : [];
14714
+ if (!dests.length) {
14715
+ 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>';
14716
+ if (totalEl) totalEl.textContent = '';
14717
+ return;
14718
+ }
14719
+ // Roll up real_clicks vs the legacy/backfill columns: prefer plc.real_clicks
14720
+ // (post-2026-05-07 per-hit log), fall back to pl.real_clicks (PostHog
14721
+ // backfill for older rows), final fallback pl.clicks (legacy counter).
14722
+ const rows = dests.map(d => {
14723
+ const kind = d.kind || 'other';
14724
+ const realClicks = Number(d.real_clicks || 0);
14725
+ const backfillReal = Number(d.backfill_real || 0);
14726
+ const legacyClicks = Number(d.legacy_clicks || 0);
14727
+ const botClicks = Number(d.bot_clicks || 0);
14728
+ const effectiveClicks = realClicks > 0 ? realClicks : (backfillReal > 0 ? backfillReal : legacyClicks);
14729
+ return {
14730
+ target_url: d.target_url || '',
14731
+ project_name: d.project_name || '',
14732
+ platform: d.platform || '',
14733
+ kind,
14734
+ audience_page_angle: d.audience_page_angle || null,
14735
+ kind_label: kind,
14736
+ posts: Number(d.posts || 0),
14737
+ codes: Number(d.codes || 0),
14738
+ real_clicks: realClicks,
14739
+ backfill_real: backfillReal,
14740
+ legacy_clicks: legacyClicks,
14741
+ bot_clicks: botClicks,
14742
+ effective_clicks: effectiveClicks,
14743
+ first_minted_at: d.first_minted_at || null,
14744
+ last_click_at: d.last_click_at || null,
14745
+ };
14746
+ });
14747
+ if (totalEl) {
14748
+ const totalClicks = rows.reduce((a, r) => a + r.effective_clicks, 0);
14749
+ totalEl.textContent = rows.length + ' destination' + (rows.length === 1 ? '' : 's') + ' \u00b7 ' + fmt(totalClicks) + ' clicks';
14750
+ }
14751
+ const fmtUrl = (_v, r) => {
14752
+ const safe = escapeHtml(r.target_url);
14753
+ return '<a href="' + safe + '" target="_blank" rel="noopener" class="top-post-link">'
14754
+ + destinationKindBadge(r.kind, r.audience_page_angle)
14755
+ + ' <span style="word-break:break-all">' + safe + '</span>'
14756
+ + '</a>';
14757
+ };
14758
+ const fmtAgo = (v) => {
14759
+ if (!v) return '\u2014';
14760
+ try {
14761
+ const d = new Date(v);
14762
+ const diff = Date.now() - d.getTime();
14763
+ const days = Math.floor(diff / 86400000);
14764
+ if (days < 1) {
14765
+ const hours = Math.floor(diff / 3600000);
14766
+ if (hours < 1) {
14767
+ const mins = Math.max(0, Math.floor(diff / 60000));
14768
+ return mins + 'm ago';
14769
+ }
14770
+ return hours + 'h ago';
14771
+ }
14772
+ return days + 'd ago';
14773
+ } catch (_e) { return '\u2014'; }
14774
+ };
14775
+ const fmtClicks = (v, r) => {
14776
+ const main = fmt(r.effective_clicks);
14777
+ const bits = [];
14778
+ if (r.real_clicks > 0) bits.push(r.real_clicks + ' real');
14779
+ else if (r.backfill_real > 0) bits.push(r.backfill_real + ' backfill');
14780
+ else if (r.legacy_clicks > 0) bits.push(r.legacy_clicks + ' legacy');
14781
+ if (r.bot_clicks > 0) bits.push(r.bot_clicks + ' bot');
14782
+ const sub = bits.length ? '<div style="font-size:11px;color:var(--text-secondary)">' + escapeHtml(bits.join(' \u00b7 ')) + '</div>' : '';
14783
+ return '<div style="font-weight:600">' + main + '</div>' + sub;
14784
+ };
14785
+ const columns = [
14786
+ { key: 'target_url', label: 'Destination', type: 'text', align: 'left', widthPct: 48, formatter: fmtUrl },
14787
+ { key: 'project_name', label: 'Project', type: 'text', align: 'left', widthPct: 10, formatter: v => escapeHtml(v) },
14788
+ { key: 'platform', label: 'Platform', type: 'text', align: 'left', widthPct: 8, formatter: v => escapeHtml(v) },
14789
+ { key: 'posts', label: 'Posts', type: 'numeric', align: 'right', widthPct: 7, formatter: fmt },
14790
+ { key: 'codes', label: 'Codes', type: 'numeric', align: 'right', widthPct: 7, formatter: fmt },
14791
+ { key: 'effective_clicks', label: 'Clicks', type: 'numeric', align: 'right', widthPct: 12, formatter: fmtClicks },
14792
+ { key: 'last_click_at', label: 'Last click', type: 'date', align: 'right', widthPct: 8, formatter: fmtAgo },
14793
+ ];
14794
+ container.innerHTML = '';
14795
+ mountSortableTable({
14796
+ containerId: 'top-links-container',
14797
+ rows,
14798
+ state: _topLinksTableState,
14799
+ storageKey: 'sa.topLinksTable.v1',
14800
+ columns,
14801
+ emptyMessage: 'No destinations in this window yet.',
14802
+ });
14803
+ }
14804
+
12732
14805
  function dmClassBadge(dm) {
12733
14806
  const status = String(dm.conversation_status || '').toLowerCase();
12734
14807
  const interest = String(dm.interest_level || '').toLowerCase();
@@ -13135,7 +15208,11 @@ function renderTopDms(payload) {
13135
15208
  clickLine = '<div class="dm-stat-line dm-stat-line-muted"><span class="dm-stat-num">—</span> <span class="dm-stat-label">clicks</span></div>';
13136
15209
  } else {
13137
15210
  const lastAt = r.short_link_last_click_at ? new Date(r.short_link_last_click_at).toLocaleString() : 'never';
13138
- const tip = '/r/' + String(r.short_link_code) + (clicks ? (' • last click: ' + lastAt) : ' • no clicks yet');
15211
+ const tipNL = String.fromCharCode(10);
15212
+ const tip = '**Short link** /r/' + String(r.short_link_code) + tipNL +
15213
+ (clicks
15214
+ ? tipNL + '**Last click:** ' + lastAt
15215
+ : tipNL + 'No clicks yet');
13139
15216
  const color = clicks > 0 ? 'var(--accent)' : 'var(--text-muted)';
13140
15217
  clickLine = '<div class="dm-stat-line" data-tooltip="' + escapeHtml(tip) + '" style="color:' + color + ';"><span class="dm-stat-num" style="font-variant-numeric:tabular-nums;">' + fmt(clicks) + '</span> <span class="dm-stat-label">clicks</span></div>';
13141
15218
  }
@@ -13145,7 +15222,14 @@ function renderTopDms(payload) {
13145
15222
  bookedLine = '<div class="dm-stat-line dm-stat-line-muted"><span class="dm-stat-num">—</span> <span class="dm-stat-label">booked</span></div>';
13146
15223
  } else {
13147
15224
  const lastAt = r.last_booking_at ? new Date(r.last_booking_at).toLocaleString() : '';
13148
- const tip = booked + ' booked' + (cancelled ? (' • ' + cancelled + ' cancelled') : '') + (lastAt ? (' • last: ' + lastAt) : '');
15225
+ const bNL = String.fromCharCode(10);
15226
+ const tipParts = ['**Bookings**', '', '• **Booked:** ' + booked];
15227
+ if (cancelled) tipParts.push('• **Cancelled:** ' + cancelled);
15228
+ if (lastAt) {
15229
+ tipParts.push('');
15230
+ tipParts.push('**Last booking:** ' + lastAt);
15231
+ }
15232
+ const tip = tipParts.join(bNL);
13149
15233
  bookedLine = '<div class="dm-stat-line" data-tooltip="' + escapeHtml(tip) + '" style="color:var(--success);font-weight:600;"><span class="dm-stat-num" style="font-variant-numeric:tabular-nums;">' + fmt(booked) + '</span> <span class="dm-stat-label">booked</span></div>';
13150
15234
  }
13151
15235
 
@@ -13158,13 +15242,19 @@ function renderTopDms(payload) {
13158
15242
  const wrapped = !!(r.booking_link_sent_at || linkCount > 0);
13159
15243
  const detected = !!r.outbound_url_detected;
13160
15244
  if (!wrapped && !detected) return '<span style="color:var(--text-faint);">No</span>';
13161
- const tipParts = [];
13162
- if (r.booking_link_sent_at) tipParts.push('booking link sent: ' + new Date(r.booking_link_sent_at).toLocaleString());
13163
- if (linkCount > 0) tipParts.push(linkCount + ' wrapped link' + (linkCount === 1 ? '' : 's'));
13164
- if (r.short_link_code) tipParts.push('latest: /r/' + String(r.short_link_code));
13165
- if (!wrapped && detected) tipParts.push('raw URL detected in outbound text (wrap pipeline bypassed - no dm_links row, click tracking missing)');
13166
- else if (detected && wrapped) tipParts.push('also: raw URL in outbound text');
13167
- const tip = tipParts.join(' ');
15245
+ const tipParts = ['**Link tracking**', ''];
15246
+ if (r.booking_link_sent_at) tipParts.push(' **Booking link sent:** ' + new Date(r.booking_link_sent_at).toLocaleString());
15247
+ if (linkCount > 0) tipParts.push('• **' + linkCount + ' wrapped link' + (linkCount === 1 ? '' : 's') + '**');
15248
+ if (r.short_link_code) tipParts.push(' **Latest:** /r/' + String(r.short_link_code));
15249
+ if (!wrapped && detected) {
15250
+ tipParts.push('');
15251
+ tipParts.push('**Raw URL detected** in outbound text');
15252
+ tipParts.push('(wrap pipeline bypassed — no dm_links row, click tracking missing)');
15253
+ } else if (detected && wrapped) {
15254
+ tipParts.push('');
15255
+ tipParts.push('Also: raw URL in outbound text');
15256
+ }
15257
+ const tip = tipParts.join(String.fromCharCode(10));
13168
15258
  const tipAttr = tip ? ' data-tooltip="' + escapeHtml(tip) + '"' : '';
13169
15259
  const label = wrapped ? 'Yes' : 'Yes*';
13170
15260
  const color = wrapped ? 'var(--success)' : '#b45309';
@@ -13960,6 +16050,7 @@ function renderProjectStatus(data, opts) {
13960
16050
  const grandCost = Number(data && data.grand_cost_usd) || 0;
13961
16051
  const grandCostOrch = Number(data && data.grand_cost_usd_orchestrator) || 0;
13962
16052
  const grandCostEst = Number(data && data.grand_cost_usd_estimated) || 0;
16053
+ const grandCostSub = Number(data && data.grand_cost_usd_subagent) || 0;
13963
16054
  // Money formatter mirrors fmtCost: $0, $0.0042, $12.34.
13964
16055
  const fmtMoney = (v) => {
13965
16056
  const n = Number(v) || 0;
@@ -13969,12 +16060,17 @@ function renderProjectStatus(data, opts) {
13969
16060
  };
13970
16061
  // Money cell with tooltip exposing SDK + estimate lanes, same UX as
13971
16062
  // moneyCell in renderCostStats so operators see consistent numbers.
13972
- const costCell = (displayed, orch, est, opts) => {
16063
+ const costCell = (displayed, orch, est, sub, opts) => {
16064
+ // SDK-only mode: displayed value comes from orchestrator_cost_usd; the
16065
+ // estimate and subagent are diagnostic-only (local pricing table).
13973
16066
  const tip = [
13974
- 'Orchestrator (SDK): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
13975
- 'Estimated (transcript): ' + (est != null ? fmtMoney(est) : 'n/a'),
16067
+ '**Cost (SDK orchestrator):** ' + (orch != null ? fmtMoney(orch) : 'n/a'),
13976
16068
  '',
13977
- 'Displayed value prefers SDK; falls back to transcript estimate. Subagent costs excluded (anthropics/claude-code #43945).',
16069
+ '**Diagnostic-only** (local pricing estimate, not actual billing)',
16070
+ '• Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
16071
+ '• Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
16072
+ '',
16073
+ '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.',
13978
16074
  ].join('\\n');
13979
16075
  const style = 'text-align:right;font-variant-numeric:tabular-nums;' + (opts && opts.extra || '');
13980
16076
  const inner = '<span data-tooltip="' + escapeHtml(tip) +
@@ -13990,10 +16086,13 @@ function renderProjectStatus(data, opts) {
13990
16086
  : base;
13991
16087
  if (costAvailable) {
13992
16088
  const tipLines = [
13993
- 'Orchestrator (SDK): ' + fmtMoney(grandCostOrch),
13994
- 'Estimated (transcript): ' + fmtMoney(grandCostEst),
16089
+ '**Cost (SDK orchestrator):** ' + fmtMoney(grandCostOrch),
16090
+ '',
16091
+ '**Diagnostic-only** (local pricing estimate, not actual billing)',
16092
+ '• Transcript estimate: ' + fmtMoney(grandCostEst),
16093
+ '• Subagent (est): ' + fmtMoney(grandCostSub),
13995
16094
  '',
13996
- '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.',
16095
+ '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.',
13997
16096
  ];
13998
16097
  totalEl.setAttribute('data-tooltip', tipLines.join('\\n'));
13999
16098
  totalEl.style.cursor = 'help';
@@ -14072,7 +16171,7 @@ function renderProjectStatus(data, opts) {
14072
16171
  : nameCell;
14073
16172
  const totalCell = cellWithShare(r.total, grandTotal, targetShare, { extra: 'font-weight:600;', showZeroShare: true });
14074
16173
  const costCellHtml = costAvailable
14075
- ? costCell(Number(r.cost_usd) || 0, Number(r.cost_usd_orchestrator) || 0, Number(r.cost_usd_estimated) || 0, { extra: 'color:var(--text-secondary);' })
16174
+ ? 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);' })
14076
16175
  : '';
14077
16176
  const weightVal = Number(r.weight) || 0;
14078
16177
  const editable = canEditWeight && (!r.unassigned || r.configured);
@@ -14108,7 +16207,7 @@ function renderProjectStatus(data, opts) {
14108
16207
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + (Number(totals[p]) || 0) + '</td>'
14109
16208
  ).join('');
14110
16209
  const footerCostCell = costAvailable
14111
- ? costCell(grandCost, grandCostOrch, grandCostEst, { extra: 'font-weight:600;' })
16210
+ ? costCell(grandCost, grandCostOrch, grandCostEst, grandCostSub, { extra: 'font-weight:600;' })
14112
16211
  : '';
14113
16212
  const footerHtml =
14114
16213
  '<tr style="border-top:2px solid var(--border);font-weight:600;background:var(--bg-subtle);">' +
@@ -14266,7 +16365,11 @@ async function loadFunnelStats(force) {
14266
16365
  if (_funnelStatsLoading) return;
14267
16366
  if (saAuthNotReady()) return;
14268
16367
  const days = currentStatsWindow().days;
14269
- if (_funnelStatsLoadedFor === days && !force) return;
16368
+ const platform = currentStatsPlatform();
16369
+ // Cache key is window+platform so a platform-pill change forces a refetch
16370
+ // even when the days dropdown stayed the same. Matches the server's cache key.
16371
+ const loadKey = days + '|' + platform;
16372
+ if (_funnelStatsLoadedFor === loadKey && !force) return;
14270
16373
  _funnelStatsLoading = true;
14271
16374
  const totalEl = document.getElementById('funnel-stats-total');
14272
16375
  const body = document.getElementById('funnel-stats-body');
@@ -14275,11 +16378,13 @@ async function loadFunnelStats(force) {
14275
16378
  body.innerHTML = '<div class="style-stats-empty">Loading\u2026 (first call can take 15\u201330s)</div>';
14276
16379
  }
14277
16380
  try {
14278
- const res = await fetch('/api/funnel/stats?days=' + days);
16381
+ const params = ['days=' + days];
16382
+ if (platform && platform !== 'all') params.push('platform=' + encodeURIComponent(platform));
16383
+ const res = await fetch('/api/funnel/stats?' + params.join('&'));
14279
16384
  const data = await res.json();
14280
16385
  if (data && !data.error) _lastFunnelPayload = data;
14281
16386
  renderFunnelStats(data);
14282
- _funnelStatsLoadedFor = days;
16387
+ _funnelStatsLoadedFor = loadKey;
14283
16388
  } catch (e) {
14284
16389
  if (body) body.innerHTML = '<div class="style-stats-empty">Failed to load.</div>';
14285
16390
  } finally {
@@ -14451,7 +16556,7 @@ function renderActivity(events) {
14451
16556
  '</div>' +
14452
16557
  '</td>' +
14453
16558
  '<td class="activity-summary">' + summaryHtml + '</td>' +
14454
- '<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>' +
16559
+ '<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>' +
14455
16560
  '<td style="text-align:center;">' + renderDeleteBtnHtml(e) + '</td>' +
14456
16561
  '</tr>';
14457
16562
  }).join('');
@@ -14662,6 +16767,15 @@ _saInstallDeleteListener();
14662
16767
  });
14663
16768
  })();
14664
16769
 
16770
+ // Wire the per-project breakdown <details> toggles so the first time the
16771
+ // user opens either one we kick off a lazy fetch. Subsequent opens reuse
16772
+ // the cache built for the current (granularity, platform) key.
16773
+ (function wirePerProjectBreakdownToggles() {
16774
+ if (typeof _wirePerProjectToggles === 'function') {
16775
+ _wirePerProjectToggles();
16776
+ }
16777
+ })();
16778
+
14665
16779
  // Lazy-load funnel stats the first time the user opens the section. The fetch
14666
16780
  // shells out to PostHog and two Postgres DBs, so we don't want to run it on
14667
16781
  // every page load.
@@ -15013,6 +17127,25 @@ function renderHtml() {
15013
17127
  .replace('__SA_POSTHOG_CONFIG_PLACEHOLDER__', JSON.stringify(posthogWebConfig()));
15014
17128
  }
15015
17129
 
17130
+ function renderTikTokOauthCallback(rawUrl) {
17131
+ const u = new URL(rawUrl, 'http://localhost');
17132
+ const code = u.searchParams.get('code') || '';
17133
+ const state = u.searchParams.get('state') || '';
17134
+ const scopes = u.searchParams.get('scopes') || '';
17135
+ const err = u.searchParams.get('error') || '';
17136
+ const errDesc = u.searchParams.get('error_description') || '';
17137
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
17138
+ const ok = !!code && !err;
17139
+ const banner = ok
17140
+ ? '<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>'
17141
+ : `<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>`;
17142
+ 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>` : '';
17143
+ 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
17144
+ 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>` : '';
17145
+ 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>` : '';
17146
+ 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>`;
17147
+ }
17148
+
15016
17149
  // --- Server ---
15017
17150
 
15018
17151
  const server = http.createServer((req, res) => {
@@ -15038,6 +17171,14 @@ const server = http.createServer((req, res) => {
15038
17171
  Promise.resolve(handleApi(req, res)).catch(e => {
15039
17172
  try { json(res, { error: e.message || String(e) }, 500); } catch {}
15040
17173
  });
17174
+ } else if (pathname === '/oauth/tiktok/callback') {
17175
+ // Minimal TikTok OAuth landing page. We do not exchange the code here
17176
+ // because the dashboard Cloud Run service does not carry the TikTok
17177
+ // client_secret. The operator pastes the displayed code into the local
17178
+ // scripts/tiktok/oauth_helper.py script which does the exchange via
17179
+ // keychain-stored credentials and writes ~/tiktok-content-api/.env.
17180
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
17181
+ res.end(renderTikTokOauthCallback(req.url));
15041
17182
  } else {
15042
17183
  res.writeHead(404);
15043
17184
  res.end('Not found');