social-autoposter 1.4.0 → 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.
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);
@@ -1102,6 +1110,20 @@ function parseTwitterBatchIdMs(batchId) {
1102
1110
  return new Date(`${y}-${mo}-${d}T${hh}:${mm}:${ss}`).getTime();
1103
1111
  }
1104
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
+
1105
1127
  async function enrichPostCommentsTwitterRuns(runs) {
1106
1128
  const txRuns = runs.filter(r =>
1107
1129
  r.job_type === 'post-comments' && r.platform_key === 'twitter'
@@ -1142,7 +1164,7 @@ async function enrichPostCommentsTwitterRuns(runs) {
1142
1164
  const candidateRows = await pq(
1143
1165
  "SELECT discovered_at, posted_at, t1_checked_at, drafted_at, " +
1144
1166
  " (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id, " +
1145
- " matched_project " +
1167
+ " matched_project, tweet_url " +
1146
1168
  "FROM twitter_candidates " +
1147
1169
  "WHERE discovered_at >= $1::timestamp OR posted_at >= $1::timestamp OR t1_checked_at >= $1::timestamp OR status='pending'",
1148
1170
  [since]
@@ -1176,12 +1198,23 @@ async function enrichPostCommentsTwitterRuns(runs) {
1176
1198
  " )"
1177
1199
  );
1178
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
+ ) || [];
1179
1207
 
1180
1208
  const toMs = (d) => {
1181
1209
  if (!d) return null;
1182
1210
  const dt = d instanceof Date ? d : new Date(d);
1183
1211
  return dt.getTime();
1184
1212
  };
1213
+ const twitterPostNorm = twitterPostRows.map(r => ({
1214
+ postedMs: toMs(r.posted_at),
1215
+ threadUrl: r.thread_url || '',
1216
+ style: r.engagement_style || '',
1217
+ }));
1185
1218
  const searchNorm = searchRows.map(r => ({
1186
1219
  ms: toMs(r.ran_at),
1187
1220
  found: r.tweets_found || 0,
@@ -1207,6 +1240,7 @@ async function enrichPostCommentsTwitterRuns(runs) {
1207
1240
  status: r.status,
1208
1241
  batch_id: r.batch_id || '',
1209
1242
  matched_project: r.matched_project || '',
1243
+ tweet_url: r.tweet_url || '',
1210
1244
  };
1211
1245
  });
1212
1246
 
@@ -1383,6 +1417,30 @@ async function enrichPostCommentsTwitterRuns(runs) {
1383
1417
  }
1384
1418
  }
1385
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] + ')'; });
1386
1444
  const prior = run.result || {};
1387
1445
  const priorDiscover = (prior.discover && typeof prior.discover === 'object') ? prior.discover : {};
1388
1446
  run.result = {
@@ -1421,6 +1479,7 @@ async function enrichPostCommentsTwitterRuns(runs) {
1421
1479
  // pill row so the operator can see at a glance which projects consumed
1422
1480
  // the cycle, even when posted=0. Mirrors enrichPostCommentsRedditRuns.
1423
1481
  projects_worked: projectsList,
1482
+ styles_used: stylesUsedTx,
1424
1483
  cost_usd: prior.cost_usd || 0,
1425
1484
  failed: prior.failed || 0,
1426
1485
  failure_reasons: Array.isArray(prior.failure_reasons) ? prior.failure_reasons : [],
@@ -1478,7 +1537,8 @@ async function enrichPostCommentsRedditRuns(runs) {
1478
1537
  const since = new Date(oldestMs - 2 * 60 * 1000).toISOString();
1479
1538
  const candidateRows = await pq(
1480
1539
  "SELECT discovered_at, posted_at, last_attempt_at, t1_checked_at, drafted_at, " +
1481
- " 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 " +
1482
1542
  "FROM reddit_candidates " +
1483
1543
  "WHERE discovered_at >= $1::timestamp " +
1484
1544
  " OR posted_at >= $1::timestamp " +
@@ -1504,12 +1564,26 @@ async function enrichPostCommentsRedditRuns(runs) {
1504
1564
  " )"
1505
1565
  );
1506
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
+ ) || [];
1507
1576
 
1508
1577
  const toMs = (d) => {
1509
1578
  if (!d) return null;
1510
1579
  const dt = d instanceof Date ? d : new Date(d);
1511
1580
  return dt.getTime();
1512
1581
  };
1582
+ const redditPostNorm = redditPostRows.map(r => ({
1583
+ id: r.id,
1584
+ postedMs: toMs(r.posted_at),
1585
+ style: r.engagement_style || '',
1586
+ }));
1513
1587
  // exitMs = the moment the row left 'pending'. Null for rows still pending.
1514
1588
  // posted -> posted_at
1515
1589
  // failed -> last_attempt_at (set by _db_mark_candidate_attempt + html_locked)
@@ -1539,6 +1613,7 @@ async function enrichPostCommentsRedditRuns(runs) {
1539
1613
  exitMs,
1540
1614
  status: r.status,
1541
1615
  batch_id: r.batch_id || '',
1616
+ post_id: r.post_id || null,
1542
1617
  };
1543
1618
  });
1544
1619
  // Filename carries the run start: run-reddit-search-YYYY-MM-DD_HHMMSS.log
@@ -1847,6 +1922,57 @@ async function enrichPostCommentsRedditRuns(runs) {
1847
1922
  + queueDrainedExpired + queueDrainedSkipped;
1848
1923
 
1849
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] + ')'; });
1850
1976
  const prior = run.result || {};
1851
1977
  // Trust the per-iter rollup `phase=post posted=N` over the bare POSTED:
1852
1978
  // grep when both exist (POSTED: can fire mid-retry). Fall back to the
@@ -1881,6 +2007,11 @@ async function enrichPostCommentsRedditRuns(runs) {
1881
2007
  // end of the dashboard pill row so the operator can see at a glance which
1882
2008
  // project(s) consumed the cycle (often 2 distinct: salvage lane + discover).
1883
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,
1884
2015
  // Ripen phase (5-min delta gate, scripts/ripen_reddit_plan.py). Reflects
1885
2016
  // the per-run sum across all iterations that reached the ripen step.
1886
2017
  // ripen_iters counts iterations where the [ripen] summary marker fired
@@ -4155,7 +4286,7 @@ async function handleApi(req, res) {
4155
4286
  // Project is case-sensitive (stored as 'Assrt', 'Cyrano', 'fazm', etc.).
4156
4287
  const rawProject = (url.searchParams.get('project') || '').trim();
4157
4288
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4158
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4289
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4159
4290
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4160
4291
  // Non-admin clients can only see projects in their claim. Reject if the
4161
4292
  // requested project isn't allowed, and force-filter the default "all" view.
@@ -4207,18 +4338,23 @@ async function handleApi(req, res) {
4207
4338
  "ELSE COALESCE(upvotes,0) END), 0)::int AS upvotes_discounted, " +
4208
4339
  "COALESCE(SUM(comments_count), 0)::int AS comments, " +
4209
4340
  "COALESCE(SUM(views) FILTER (WHERE LOWER(platform) NOT IN ('moltbook', 'github', 'github_issues')), 0)::int AS views, " +
4210
- // post_clicks: SUM of post_links.clicks attributable to short links
4211
- // minted for these posts (post_id-keyed). Reply-keyed clicks are
4212
- // excluded so we don't double-count engagement on replies that hang
4213
- // 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.
4214
4346
  "COALESCE(SUM(pl.total_clicks), 0)::int AS post_clicks, " +
4215
4347
  // Intent dimension (is_recommendation) is independent of tone (engagement_style).
4216
4348
  // This sum tells us "of N posts in this tone, how many carried a project mention".
4217
4349
  "COALESCE(SUM(CASE WHEN is_recommendation THEN 1 ELSE 0 END), 0)::int AS recommendations " +
4218
4350
  "FROM posts " +
4219
4351
  "LEFT JOIN (" +
4220
- "SELECT post_id, SUM(clicks)::int AS total_clicks " +
4221
- "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" +
4222
4358
  ") pl ON pl.post_id = posts.id " +
4223
4359
  "WHERE posted_at >= NOW() - INTERVAL '" + windowHours + " hours' " +
4224
4360
  "AND our_content <> '(mention - no original post)' " +
@@ -4246,9 +4382,10 @@ async function handleApi(req, res) {
4246
4382
  }
4247
4383
 
4248
4384
  // GET /api/cohort/stats - posts bucketed into 4 score cohorts over a trailing window.
4249
- // Score formula matches top_performers.py SCORE_SQL:
4250
- // score = comments_count*3 + upvotes (Reddit/Moltbook: -1 to strip OP self-upvote)
4251
- // 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).
4252
4389
  // Honors the same window/platform/project filters as the rest of the Stats tab.
4253
4390
  if (p === '/api/cohort/stats' && req.method === 'GET') {
4254
4391
  const url = new URL(req.url, 'http://localhost');
@@ -4260,7 +4397,7 @@ async function handleApi(req, res) {
4260
4397
  if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
4261
4398
  const rawProject = (url.searchParams.get('project') || '').trim();
4262
4399
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4263
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4400
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4264
4401
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4265
4402
  const cohortPc = auth.projectClause(req.user, 'project_name', project || null);
4266
4403
  if (!cohortPc.ok) return json(res, { windowHours, platform: platform || 'all', project: project || 'all', rows: [], totalPosts: 0 });
@@ -4275,17 +4412,27 @@ async function handleApi(req, res) {
4275
4412
  : '';
4276
4413
  const projectFilter = cohortPc.clause ? cohortPc.clause + ' '
4277
4414
  : (project ? "AND project_name = '" + project.replace(/'/g, "''") + "' " : '');
4278
- // 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).
4279
4419
  const scoreExpr =
4280
- "(COALESCE(comments_count,0) * 3 + " +
4420
+ "(COALESCE(clicks, 0) * 10 + " +
4421
+ "COALESCE(comments_count,0) * 3 + " +
4281
4422
  "CASE WHEN LOWER(platform) IN ('reddit', 'moltbook') " +
4282
4423
  "THEN GREATEST(0, COALESCE(upvotes,0) - 1) " +
4283
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
4284
4431
  const cohortExpr =
4285
4432
  "CASE " +
4286
4433
  "WHEN " + scoreExpr + " = 0 THEN 'dead' " +
4287
- "WHEN " + scoreExpr + " BETWEEN 1 AND 4 THEN 'low' " +
4288
- "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' " +
4289
4436
  "ELSE 'high' END";
4290
4437
  // Views excluded from moltbook/github (no public view counter on those
4291
4438
  // platforms); use FILTER so the views range only reflects platforms that
@@ -4314,11 +4461,20 @@ async function handleApi(req, res) {
4314
4461
  "MAX(COALESCE(views,0)) " + viewsFilter + " AS max_views, " +
4315
4462
  "(AVG(COALESCE(views,0)) " + viewsFilter + ")::numeric(10,0) AS avg_views " +
4316
4463
  "FROM (" +
4317
- "SELECT platform, " + upvotesNetExpr + " AS upvotes_net, " +
4464
+ "SELECT posts.platform, " + upvotesNetExpr + " AS upvotes_net, " +
4465
+ "COALESCE(pl.total_clicks, 0) AS clicks, " +
4318
4466
  "comments_count, views, " +
4319
4467
  scoreExpr + " AS score, " +
4320
4468
  cohortExpr + " AS cohort " +
4321
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 " +
4322
4478
  "WHERE posted_at >= NOW() - INTERVAL '" + windowHours + " hours' " +
4323
4479
  "AND upvotes IS NOT NULL " +
4324
4480
  "AND our_content <> '(mention - no original post)' " +
@@ -4507,7 +4663,7 @@ async function handleApi(req, res) {
4507
4663
  if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
4508
4664
  const rawProject = (url.searchParams.get('project') || '').trim();
4509
4665
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4510
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4666
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4511
4667
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4512
4668
  const cacheKey = days + '|' + platform + '|' + project;
4513
4669
  const cached = cache.get(cacheKey);
@@ -4587,7 +4743,7 @@ async function handleApi(req, res) {
4587
4743
  if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
4588
4744
  const rawProject = (url.searchParams.get('project') || '').trim();
4589
4745
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4590
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4746
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4591
4747
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4592
4748
  const cacheKey = days + '|' + platform + '|' + project;
4593
4749
  const cached = clicksPerDayCache.get(cacheKey);
@@ -4639,7 +4795,7 @@ async function handleApi(req, res) {
4639
4795
  if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
4640
4796
  const rawProject = (url.searchParams.get('project') || '').trim();
4641
4797
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4642
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4798
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4643
4799
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4644
4800
  const cacheKey = days + '|' + platform + '|' + project;
4645
4801
  const cached = postsPerDayCache.get(cacheKey);
@@ -4690,7 +4846,7 @@ async function handleApi(req, res) {
4690
4846
  const days = Math.max(1, Math.min(365, parseInt(url.searchParams.get('days') || '30', 10) || 30));
4691
4847
  const rawProject = (url.searchParams.get('project') || '').trim();
4692
4848
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4693
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4849
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4694
4850
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4695
4851
  const cacheKey = days + '|' + project;
4696
4852
  const cached = bookingsPerDayCache.get(cacheKey);
@@ -4730,7 +4886,7 @@ async function handleApi(req, res) {
4730
4886
  const days = Math.max(1, Math.min(90, parseInt(url.searchParams.get('days') || '30', 10) || 30));
4731
4887
  const rawProject = (url.searchParams.get('project') || '').trim();
4732
4888
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4733
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
4889
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4734
4890
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4735
4891
  const cacheKey = days + '|' + project;
4736
4892
  const cached = funnelPerDayCache.get(cacheKey);
@@ -4921,7 +5077,7 @@ async function handleApi(req, res) {
4921
5077
  const days = Math.max(1, Math.min(365, parseInt(url.searchParams.get('days') || '30', 10) || 30));
4922
5078
  const rawProject = (url.searchParams.get('project') || '').trim();
4923
5079
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
4924
- const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
5080
+ const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
4925
5081
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
4926
5082
  const ALLOWED_COST_PLATFORMS = new Set(['reddit', 'twitter', 'linkedin', 'moltbook', 'github', 'seo', 'email']);
4927
5083
  let rawPlat = String(url.searchParams.get('platform') || '').toLowerCase().trim();
@@ -5867,36 +6023,44 @@ async function handleApi(req, res) {
5867
6023
  // slow (~15-30s), so we cache for 10 min and dedupe concurrent callers.
5868
6024
  // A launchd timer (com.m13v.social-precompute-stats) also writes fresh
5869
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.
5870
6037
  if (p === '/api/funnel/stats' && req.method === 'GET') {
5871
6038
  const url = new URL(req.url, 'http://localhost');
5872
6039
  const days = Math.max(1, Math.min(90, parseInt(url.searchParams.get('days') || '1', 10) || 1));
5873
- 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);
5874
6047
  const TTL_MS = 600000;
5875
6048
  if (entry && entry.value && Date.now() - entry.at < TTL_MS) {
5876
- return json(res, scopeFunnelStatsPayload({ days, ...entry.value, cachedAt: entry.at }, req.user));
5877
- }
5878
- const snap = await readSnapshotCached(`funnel_stats_${days}d.json`);
5879
- if (snap && snap.value && !snap.value.error) {
5880
- // Warm the in-memory cache so subsequent hits skip the disk read too.
5881
- funnelStatsCache.set(days, { at: snap.at, value: snap.value });
5882
- 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));
5883
6050
  }
5884
6051
  if (entry && entry.pending) {
5885
- 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)))
5886
6053
  .catch(err => json(res, { error: String(err && err.message || err) }, 500));
5887
6054
  return;
5888
6055
  }
5889
- // Cloud Run has no python runtime and no PostHog creds; only the
5890
- // operator's local server can run the live pipeline. Return whatever
5891
- // we've got (empty snapshot if nothing) rather than hanging.
5892
6056
  if (auth.CLIENT_MODE) {
5893
- return json(res, { days, error: 'snapshot_missing', cachedAt: null }, 503);
6057
+ return json(res, { days, platform: platform || 'all', error: 'snapshot_missing', cachedAt: null }, 503);
5894
6058
  }
6059
+
5895
6060
  const scriptPath = path.join(DEST, 'scripts', 'project_stats_json.py');
5896
- const pending = new Promise((resolve, reject) => {
5897
- const child = spawn('python3', [scriptPath, '--days', String(days)], {
5898
- env: process.env, cwd: DEST,
5899
- });
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 });
5900
6064
  let out = '', err = '';
5901
6065
  child.stdout.on('data', d => out += d);
5902
6066
  child.stderr.on('data', d => err += d);
@@ -5906,12 +6070,69 @@ async function handleApi(req, res) {
5906
6070
  try { resolve(JSON.parse(out)); } catch (e) { reject(e); }
5907
6071
  });
5908
6072
  });
5909
- 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 });
5910
6131
  pending.then(val => {
5911
- funnelStatsCache.set(days, { at: Date.now(), value: val });
5912
- 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));
5913
6134
  }).catch(err => {
5914
- funnelStatsCache.delete(days);
6135
+ funnelStatsCache.delete(cacheKey);
5915
6136
  json(res, { error: String(err && err.message || err) }, 500);
5916
6137
  });
5917
6138
  return;
@@ -7209,6 +7430,34 @@ const HTML = `<!DOCTYPE html>
7209
7430
  .daily-metrics-tooltip .tt-row { display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; color: var(--text); }
7210
7431
  .daily-metrics-tooltip .tt-row .swatch { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
7211
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); }
7212
7461
  /* Deploy Health: slim inline bar when collapsed, alert colors when there is something worth attention */
7213
7462
  #deploy-health:not([open]) { margin-bottom: 10px; border-radius: 8px; }
7214
7463
  #deploy-health:not([open]) > summary { padding: 6px 14px; }
@@ -7509,7 +7758,7 @@ const HTML = `<!DOCTYPE html>
7509
7758
  </div>
7510
7759
  <details class="style-stats-section" id="cohort-stats" open>
7511
7760
  <summary>
7512
- <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>
7513
7762
  <span class="style-stats-total" id="cohort-stats-total"></span>
7514
7763
  </summary>
7515
7764
  <div id="cohort-stats-body">
@@ -7587,6 +7836,16 @@ const HTML = `<!DOCTYPE html>
7587
7836
  <div id="daily-metrics-chart" class="daily-metrics-chart">
7588
7837
  <div class="views-chart-empty">Loading…</div>
7589
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>
7590
7849
  </div>
7591
7850
  </details>
7592
7851
  <details class="style-stats-section" id="ratio-metrics" open>
@@ -7599,6 +7858,16 @@ const HTML = `<!DOCTYPE html>
7599
7858
  <div id="ratio-metrics-chart" class="daily-metrics-chart">
7600
7859
  <div class="views-chart-empty">Loading&hellip;</div>
7601
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>
7602
7871
  </div>
7603
7872
  </details>
7604
7873
  </div>
@@ -7916,11 +8185,31 @@ function toast(msg, isError) {
7916
8185
  tipEl.style.left = left + 'px';
7917
8186
  tipEl.style.top = top + 'px';
7918
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
+ }
7919
8208
  function show(host) {
7920
8209
  const text = getText(host);
7921
8210
  if (!text) return;
7922
8211
  const el = ensureTip();
7923
- el.textContent = text;
8212
+ el.innerHTML = renderTooltipMarkup(text);
7924
8213
  el.classList.add('visible');
7925
8214
  position(host);
7926
8215
  currentHost = host;
@@ -8326,12 +8615,14 @@ function renderResult(run) {
8326
8615
  const unmatchedN = scan.unmatched || 0;
8327
8616
  const backfillN = scan.backfill || 0;
8328
8617
  const hasScan = scannedN || newN || excludedN || unmatchedN || backfillN;
8618
+ const SNL = String.fromCharCode(10);
8329
8619
  const scanTooltip = hasScan
8330
- ? ('inbox scan: seen=' + scannedN +
8331
- ' / new=' + newN +
8332
- ' / excluded=' + excludedN +
8333
- ' / unmatched=' + unmatchedN +
8334
- ' / 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)
8335
8626
  : '';
8336
8627
  const scanPills = hasScan
8337
8628
  ? ('<span title="' + scanTooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
@@ -8365,7 +8656,8 @@ function renderResult(run) {
8365
8656
  if (!failed && !reasons.length) return '';
8366
8657
  const top = reasons[0];
8367
8658
  const tt = reasons.length
8368
- ? 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))
8369
8661
  : 'failed (no reason logged)';
8370
8662
  const label = top
8371
8663
  ? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
@@ -8375,12 +8667,16 @@ function renderResult(run) {
8375
8667
  'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
8376
8668
  label + (count ? ' <span style="color:var(--text);font-weight:600;">' + count + '</span>' : '') + '</span>';
8377
8669
  };
8378
- const tooltip = 'searches: ' + searches +
8379
- ' / raw SERP candidates: ' + raw +
8380
- ' / passed 20.0 floor: ' + passed +
8381
- ' / dropped below floor: ' + dropped +
8382
- ' / posted: ' + posted +
8383
- ' / 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;
8384
8680
  return (
8385
8681
  '<span title="' + tooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
8386
8682
  pill('searches', searches, searches > 0 ? 'var(--text)' : 'var(--muted)') +
@@ -8431,7 +8727,8 @@ function renderResult(run) {
8431
8727
  if (!failed && !reasons.length) return '';
8432
8728
  const top = reasons[0];
8433
8729
  const tt = reasons.length
8434
- ? 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))
8435
8732
  : 'failed (no reason logged)';
8436
8733
  const label = top
8437
8734
  ? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
@@ -8466,27 +8763,27 @@ function renderResult(run) {
8466
8763
  // CSS .sa-tooltip white-space:pre-line turns these into line breaks.
8467
8764
  const NL = String.fromCharCode(10);
8468
8765
  const tooltip =
8469
- 'Phase 0 (cleanup):' + NL +
8470
- '• salvaged ' + salvAttempted + ': orphan rows adopted from prior dead cycles (' + salvPosted + ' posted this cycle)' + NL +
8471
- '• pool for next cycle: ' + salvageableLive + ' salvageable (+' + salvAdded + ' / -' + salvDrained + ' this run)' + NL +
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 +
8472
8769
  NL +
8473
- 'Phase 1 (scrape):' + NL +
8474
- '• searches ' + searches + ': queries run' + NL +
8475
- '• raw ' + raw + ': tweets returned' + NL +
8476
- '• passed ' + passed + ': after dedup + age>18h cuts (' + dropped + ' dropped)' + NL +
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 +
8477
8774
  NL +
8478
- 'Phase 2a (Δ re-score):' + NL +
8479
- '• expired ' + expired + ': below Δ<1 likes floor' + NL +
8775
+ '**Phase 2a Δ re-score**' + NL +
8776
+ '• **expired ' + expired + '** below Δ<1 likes floor' + NL +
8480
8777
  NL +
8481
- 'Phase 2b (draft + post):' + NL +
8482
- '• Δ≥10 ' + aboveFloor + ': crossed POST_LIMIT=3 review cap' + NL +
8483
- '• posted ' + posted + ': shipped' + NL +
8484
- '• failed ' + failed + ': post errors' + NL +
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 +
8485
8782
  NL +
8486
- 'Pending end-of-run: ' + queue +
8487
- ' (start ' + queueStart + ', +' + qAdded + ' / -' + qDrained + ' = ' +
8488
- qDrainedPosted + ' posted, ' + qDrainedExpired + ' expired, ' + qDrainedSkipped + ' skipped)' + NL +
8489
- 'Pending live: ' + pendingLive;
8783
+ '**Pending end-of-run:** ' + queue + NL +
8784
+ ' start ' + queueStart + ', +' + qAdded + ' / -' + qDrained + ' = ' +
8785
+ qDrainedPosted + ' posted, ' + qDrainedExpired + ' expired, ' + qDrainedSkipped + ' skipped' + NL +
8786
+ '**Pending live:** ' + pendingLive;
8490
8787
  // Pill order mirrors the tooltip story: salvaged (Phase 0 input) leads,
8491
8788
  // then Phase 1 funnel (searches, raw, passed), Phase 2a drop (expired),
8492
8789
  // Phase 2b decision and outcome (Δ≥10, posted, failed).
@@ -8507,6 +8804,13 @@ function renderResult(run) {
8507
8804
  + r.projects_worked.join(', ')
8508
8805
  + '</span></span>'
8509
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
+ : '') +
8510
8814
  '</span>'
8511
8815
  );
8512
8816
  }
@@ -8563,7 +8867,8 @@ function renderResult(run) {
8563
8867
  ? ' (' + reasons.map(function (x) { return x.reason + ' ' + x.count; }).join('; ') + ')'
8564
8868
  : '';
8565
8869
  const tt = reasons.length
8566
- ? 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))
8567
8872
  : 'failed (no reason logged)';
8568
8873
  return '<span title="' + tt.replace(/"/g, '&quot;') + '" ' +
8569
8874
  'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
@@ -8587,17 +8892,28 @@ function renderResult(run) {
8587
8892
  // now means "threads that ACTUALLY came through the ripen step
8588
8893
  // with positive momentum", which is what the word implies in plain
8589
8894
  // English. Final chain: raw → passed → ripened → drafted → posted.
8590
- const ripenTip = 'ripen iters: ' + ripenIters +
8591
- ' / input decisions (=passed): ' + ripenInput +
8592
- ' / ripened (composite > ' + (ripenFloor != null ? ripenFloor : '?') + '): ' + ripenSurvivors +
8593
- ' / drops: ' + ripenDrops +
8594
- ' / iters skipped (0 survivors): ' + ripenSkipped +
8595
- ' / iters passthrough (no urls / rate limit): ' + ripenPassthrough +
8596
- (ripenWindow != null ? ' / window: ' + ripenWindow + 's' : '') +
8597
- (ripenW != null ? ' / formula: Δup + ' + ripenW + '*Δcomments' : '') +
8598
- (bestComp != null ? ' / best: composite=' + bestComp.toFixed(1) +
8599
- ' (Δup=' + (bestDup != null ? bestDup : '?') +
8600
- ', Δ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
+ : '');
8601
8917
  const bestLabel = bestComp != null
8602
8918
  ? ('best Δ' + bestComp.toFixed(1))
8603
8919
  : (ripenSurvivors > 0 ? 'best Δ?' : 'no Δ');
@@ -8654,35 +8970,55 @@ function renderResult(run) {
8654
8970
  bracket = ' <span style="color:var(--muted);font-weight:400;">(' +
8655
8971
  '+' + salvAdded + '/-' + salvDrained + ' pool)</span>';
8656
8972
  }
8657
- const qTip = 'salvage attempts this run (Phase 0 row pulls): ' + salvAttempted +
8658
- ' / salvage lane posted: ' + salvPosted +
8659
- ' / salvage lane failed: ' + salvFailedNow +
8660
- ' / salvageable now (pool size for next cycle): ' + salvageableLive +
8661
- ' (+' + salvAdded + ' became salvageable / -' + salvDrained + ' drained out)' +
8662
- ' / pending pool end-of-run: ' + queue + ' (start: ' + queueStartV +
8663
- ', +' + qAdded + ' added, -' + qDrained + ' drained = ' +
8664
- qDrainedPosted + ' posted + ' + qDrainedFailed + ' failed + ' +
8665
- qDrainedExpired + ' expired + ' + qDrainedSkipped + ' skipped)' +
8666
- ' / 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;
8667
8990
  return '<span title="' + qTip.replace(/"/g, '&quot;') + '" ' +
8668
8991
  'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
8669
8992
  'salvaged <span style="color:var(--text);font-weight:600;">' + primaryN + '</span>' +
8670
8993
  bracket + '</span>';
8671
8994
  };
8672
8995
 
8673
- const tooltip = 'iterations: ' + iterations +
8674
- ' / searches: ' + searches +
8675
- ' / raw API results: ' + raw +
8676
- ' / passed (post-API filter): ' + passed +
8677
- ' / dropped (blocked sub / archived / locked / age>180d): ' + dropped +
8678
- ' / fetched (model opened to read): ' + fetched +
8679
- ' / drafted: ' + drafted +
8680
- (ripenIters ? ' / ripen survivors: ' + ripenSurvivors + '/' + ripenInput +
8681
- (bestComp != null ? ' (best composite ' + bestComp.toFixed(1) + ')' : '') : '') +
8682
- ' / posted: ' + posted +
8683
- (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 : '');
8684
9013
  return (
8685
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() +
8686
9022
  pill('iterations', iterations, iterations > 0 ? 'var(--text)' : 'var(--muted)') +
8687
9023
  pill('searches', searches, searches > 0 ? 'var(--text)' : 'var(--muted)') +
8688
9024
  pill('raw', raw, raw > 0 ? 'var(--text)' : 'var(--muted)') +
@@ -8696,7 +9032,6 @@ function renderResult(run) {
8696
9032
  renderRipenPills() +
8697
9033
  pill('drafted', drafted, drafted > 0 ? 'var(--text)' : 'var(--muted)') +
8698
9034
  pill('posted', posted, posted > 0 ? '#22c55e' : 'var(--muted)') +
8699
- renderQueuePill() +
8700
9035
  renderFailedPill() +
8701
9036
  (Array.isArray(r.projects_worked) && r.projects_worked.length
8702
9037
  ? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
@@ -8705,6 +9040,13 @@ function renderResult(run) {
8705
9040
  + r.projects_worked.join(', ')
8706
9041
  + '</span></span>'
8707
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
+ : '') +
8708
9050
  '</span>'
8709
9051
  );
8710
9052
  }
@@ -8732,7 +9074,8 @@ function renderResult(run) {
8732
9074
  if (!failed) return '';
8733
9075
  const top = reasons[0];
8734
9076
  const tooltip = reasons.length
8735
- ? 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))
8736
9079
  : 'failed (no reason logged)';
8737
9080
  const label = top
8738
9081
  ? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
@@ -8752,12 +9095,14 @@ function renderResult(run) {
8752
9095
  const unmatchedN = scan.unmatched || 0;
8753
9096
  const backfillN = scan.backfill || 0;
8754
9097
  const hasScan = scannedN || newN || excludedN || unmatchedN || backfillN;
9098
+ const SNL = String.fromCharCode(10);
8755
9099
  const scanTooltip = hasScan
8756
- ? ('inbox scan: seen=' + scannedN +
8757
- ' / new=' + newN +
8758
- ' / excluded=' + excludedN +
8759
- ' / unmatched=' + unmatchedN +
8760
- ' / 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)
8761
9106
  : '';
8762
9107
  const scanPills = hasScan
8763
9108
  ? ('<span title="' + scanTooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
@@ -8831,48 +9176,54 @@ function renderResult(run) {
8831
9176
  };
8832
9177
  return (
8833
9178
  tipPill('scanned', scanned, 'var(--text)',
8834
- 'Total rows the run considered (every active row in the relevant ' +
8835
- 'platform tables). = checked + skipped + bypassed-as-fresh.') +
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.') +
8836
9182
  (skipped ? tipPill('skipped', skipped, 'var(--muted)',
8837
- 'Rows we deliberately did NOT poll this run. Two reasons: ' +
8838
- '(1) already refreshed by the cheap scrape leg within the last 4h, ' +
8839
- '(2) stable cooldown (2+ scans with no metric change AND older than ' +
8840
- '3 days). Saves API calls; data is still current.') : '') +
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.') : '') +
8841
9189
  tipPill('checked', checked, 'var(--text)',
8842
- 'Rows we actually hit the platform API for this run ' +
8843
- '(Reddit old.reddit.com JSON / fxtwitter / LinkedIn activity feed). ' +
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' +
8844
9192
  'Includes both successful polls and the ones that errored mid-fetch.') +
8845
9193
  tipPill('changed', changed, '#22c55e',
8846
- 'Subset of CHECKED where any tracked metric (upvotes, ' +
8847
- 'comments_count, views) actually moved since the prior scan. ' +
9194
+ '**Changed**\\n\\n' +
9195
+ 'Subset of CHECKED where any tracked metric (upvotes, comments_count, views) actually moved since the prior scan.\\n\\n' +
8848
9196
  'The real-activity signal; everything else is no-op polling.') +
8849
9197
  (viewsRefreshed ? tipPill('views', viewsRefreshed, '#06b6d4',
8850
- 'Rows where the cheap view-scrape leg wrote a fresh view count ' +
8851
- 'this run. Reddit: Step 1 profile-page scrape (sees every ' +
8852
- 'comment + thread on /user/<name>/). Twitter: built-in to the ' +
8853
- 'fxtwitter call. Separate from CHANGED because views can ' +
8854
- 'tick up without upvotes/comments moving.') : '') +
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.') : '') +
8855
9203
  (repliesRefreshed ? tipPill('replies', repliesRefreshed, '#3b82f6',
8856
- 'Per-reply rows refreshed: comments we authored AS replies to ' +
8857
- 'someone else reply to our original comment (the DM-rail ' +
8858
- 'follow-up). Live in the replies table, not posts. ' +
8859
- 'Reddit refreshes upvotes + reply-count via batch JSON API. ' +
8860
- 'Twitter also refreshes views via fxtwitter.') : '') +
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') : '') +
8861
9208
  (removed ? tipPill('removed', removed, '#eab308',
8862
- 'Posts newly flagged deleted/removed this run. Reddit: comment ' +
8863
- 'gone from thread JSON for 2+ consecutive scans (deletion_detect_' +
8864
- 'count threshold). LinkedIn: post returned "Post unavailable".') : '') +
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"') : '') +
8865
9213
  (unavailable ? tipPill('unavail', unavailable, '#eab308',
8866
- 'LinkedIn only: post explicitly returned a Post Unavailable ' +
8867
- 'string. Subset of REMOVED; rendered as its own pill so an ' +
8868
- 'operator can tell hard-deletion from rate-limit or network noise.') : '') +
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.') : '') +
8869
9217
  (notFound ? tipPill('not found', notFound, 'var(--muted)',
8870
- 'LinkedIn only: post is still active on LinkedIn but our specific ' +
8871
- 'comment couldn\'t be located on the activity feed (may have ' +
8872
- 'aged off our visible recent-activity window).') : '') +
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).') : '') +
8873
9220
  (failed ? tipPill('failed', failed, '#ef4444',
8874
- 'API errors during the run, broken down by category: 404 not_found, ' +
8875
- 'rate-limited (429), empty / malformed response, or other / network. ' +
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' +
8876
9227
  'Includes step-exit failures from the shell pipeline as well.') : '')
8877
9228
  );
8878
9229
  }
@@ -8891,7 +9242,8 @@ function renderResult(run) {
8891
9242
  if (!failed && !reasons.length) return '';
8892
9243
  const top = reasons[0];
8893
9244
  const tt = reasons.length
8894
- ? 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))
8895
9247
  : 'failed (no reason logged)';
8896
9248
  const label = top
8897
9249
  ? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
@@ -8901,10 +9253,16 @@ function renderResult(run) {
8901
9253
  'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
8902
9254
  label + (count ? ' <span style="color:var(--text);font-weight:600;">' + count + '</span>' : '') + '</span>';
8903
9255
  };
8904
- const tooltip = 'total pages in scope (zero-click 30+ days, all projects): ' + total +
8905
- ' / deleted this run: ' + deleted +
8906
- ' / queue (waiting for next run, capped by DAILY_MAX): ' + queue +
8907
- ' / 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;
8908
9266
  return (
8909
9267
  '<span title="' + tooltip.replace(/"/g, '&quot;') + '" style="display:inline-block;">' +
8910
9268
  pill('total pages', total, total > 0 ? 'var(--text)' : 'var(--muted)') +
@@ -8929,7 +9287,8 @@ function renderResult(run) {
8929
9287
  if (!failed && !reasons.length) return '';
8930
9288
  const top = reasons[0];
8931
9289
  const tooltip = reasons.length
8932
- ? 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))
8933
9292
  : 'failed (no reason logged)';
8934
9293
  const label = top
8935
9294
  ? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
@@ -8946,8 +9305,10 @@ function renderResult(run) {
8946
9305
  const renderSkipReasonsPill = () => {
8947
9306
  if (!skipBreakdown.length) return '';
8948
9307
  const top = skipBreakdown[0];
8949
- const tooltip = skipBreakdown
8950
- .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));
8951
9312
  const label = 'skipped: ' + top.reason +
8952
9313
  (skipBreakdown.length > 1 ? ' +' + (skipBreakdown.length - 1) : '');
8953
9314
  const count = top.count;
@@ -8969,10 +9330,17 @@ function renderResult(run) {
8969
9330
  const tp = discover.tweets_pulled || 0;
8970
9331
  const c = discover.candidates || 0;
8971
9332
  const af = discover.above_floor || 0;
8972
- const tip = (q + ' queries' + (d ? ' (' + d + ' duds)' : '')) + ' \u2192 ' +
8973
- (tp + ' tweets pulled') + ' \u2192 ' +
8974
- (c + ' candidates after floor') + ' \u2192 ' +
8975
- (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';
8976
9344
  const visStr = q + '\u2192' + c + '\u2192' + af;
8977
9345
  const color = (q || c || af) ? 'var(--text)' : 'var(--muted)';
8978
9346
  return '<span title="' + tip.replace(/"/g, '&quot;') + '" ' +
@@ -9115,16 +9483,16 @@ function _jobHistoryCostCell(result) {
9115
9483
  headerHtml = fmtCost(totalForDisplay);
9116
9484
  }
9117
9485
  const lines = [
9118
- 'Cost (SDK orchestrator): ' + (sessionsWithSdk > 0 ? fmtLane(orch) : 'n/a'),
9486
+ '**Cost (SDK orchestrator):** ' + (sessionsWithSdk > 0 ? fmtLane(orch) : 'n/a'),
9119
9487
  ];
9120
9488
  if (sessionsAll > 0) {
9121
- lines.push(' Sessions: ' + sessionsAll +
9122
- ' · with SDK data: ' + sessionsWithSdk +
9123
- ' · missing SDK: ' + sessionsMissing);
9489
+ lines.push('Sessions: **' + sessionsAll + '**');
9490
+ lines.push('with SDK data: **' + sessionsWithSdk + '**');
9491
+ lines.push('missing SDK: **' + sessionsMissing + '**');
9124
9492
  }
9125
9493
  if (bd && Array.isArray(bd.phases) && bd.phases.length) {
9126
9494
  lines.push('');
9127
- lines.push('Per-phase (claude_sessions.script grouping):');
9495
+ lines.push('**Per-phase** (claude_sessions.script grouping)');
9128
9496
  const shown = bd.phases.slice(0, 10);
9129
9497
  for (const p of shown) {
9130
9498
  const missing = (p.sessions_missing_sdk && p.sessions_missing_sdk > 0)
@@ -9133,7 +9501,7 @@ function _jobHistoryCostCell(result) {
9133
9501
  const orchVal = (p.sessions_with_sdk && p.sessions_with_sdk > 0)
9134
9502
  ? fmtLane(p.orch)
9135
9503
  : 'n/a';
9136
- lines.push(' ' + (p.phase || '(unknown)') + ' x' + p.sessions +
9504
+ lines.push('' + (p.phase || '(unknown)') + ' ×' + p.sessions +
9137
9505
  ' ' + orchVal + missing);
9138
9506
  }
9139
9507
  if (bd.phases.length > shown.length) {
@@ -9142,12 +9510,12 @@ function _jobHistoryCostCell(result) {
9142
9510
  }
9143
9511
  if (typeof result.cost_usd_from_log === 'number') {
9144
9512
  lines.push('');
9145
- lines.push('Wrapper shell-log value: ' + fmtLane(result.cost_usd_from_log));
9513
+ lines.push('**Wrapper shell-log value:** ' + fmtLane(result.cost_usd_from_log));
9146
9514
  }
9147
9515
  lines.push('');
9148
- lines.push('Diagnostic-only (local pricing estimate, not actual billing):');
9149
- lines.push(' Transcript estimate: ' + fmtLane(est));
9150
- lines.push(' Subagent (est): ' + fmtLane(sub));
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));
9151
9519
  lines.push('');
9152
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.');
9153
9521
  const tip = lines.join('\\n');
@@ -10085,11 +10453,11 @@ function fmtCostCell(displayed, orchestrator, estimated, subagent) {
10085
10453
  ? fmtCost(Number(orchestrator))
10086
10454
  : '<span style="color:var(--text-very-faint);">n/a</span>';
10087
10455
  const lines = [
10088
- 'Cost (SDK orchestrator): ' + fmtLane(orchestrator),
10456
+ '**Cost (SDK orchestrator):** ' + fmtLane(orchestrator),
10089
10457
  '',
10090
- 'Diagnostic-only (not actual billing):',
10091
- ' Transcript estimate: ' + fmtLane(estimated),
10092
- ' Subagent (est): ' + fmtLane(subagent),
10458
+ '**Diagnostic-only** (not actual billing)',
10459
+ 'Transcript estimate: ' + fmtLane(estimated),
10460
+ 'Subagent (est): ' + fmtLane(subagent),
10093
10461
  '',
10094
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.',
10095
10463
  ];
@@ -10404,8 +10772,12 @@ async function reloadStatsTabSections() {
10404
10772
  // of the filter bar.
10405
10773
  const funnelEl = document.getElementById('funnel-stats');
10406
10774
  if (funnelEl && funnelEl.open) {
10407
- if (_lastFunnelPayload) renderFunnelStats(_lastFunnelPayload);
10408
- else pending.push(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));
10409
10781
  }
10410
10782
  const dmEl = document.getElementById('dm-stats');
10411
10783
  if (dmEl && dmEl.open) pending.push(loadDmStats(true));
@@ -10733,6 +11105,13 @@ function renderDailyMetrics() {
10733
11105
  if (active.has(id)) active.delete(id); else active.add(id);
10734
11106
  _saveDailyMetricsActive();
10735
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 {}
10736
11115
  });
10737
11116
  });
10738
11117
 
@@ -11056,6 +11435,13 @@ function renderRatioMetrics() {
11056
11435
  if (active.has(id)) active.delete(id); else active.add(id);
11057
11436
  _saveRatioMetricsActive();
11058
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 {}
11059
11445
  });
11060
11446
  });
11061
11447
  const visible = RATIO_METRICS.filter(r => active.has(r.id));
@@ -11204,6 +11590,508 @@ function renderRatioMetrics() {
11204
11590
  }
11205
11591
  }
11206
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
+
11207
12095
  // Trends-tab filter state. Selection is read off the trends pill rows by
11208
12096
  // data-selected; granularity drives the day count and axis bucketing.
11209
12097
  function currentTrendsPlatform() {
@@ -11279,13 +12167,14 @@ async function loadDailyMetrics() {
11279
12167
  // or any transient 5xx) renders the affected series as flat zeros instead
11280
12168
  // of killing the whole chart. The "Unable to load daily metrics" fallback
11281
12169
  // below now only triggers when literally every fetch failed.
11282
- // Per-endpoint timeout. /api/funnel/per-day shells out to PostHog +
11283
- // Postgres and at days=91 (weekly window) it can hang well past 15s,
11284
- // which previously froze the entire chart because Promise.all waited on
11285
- // the slowest of 5. Cap each fetch at ~9s so a slow endpoint degrades
11286
- // gracefully (renders as flat zeros with the "rendered N of 5" note in
11287
- // the status pill) instead of leaving a permanent "Loading…" placeholder.
11288
- 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;
11289
12178
  const fetchOne = async (url) => {
11290
12179
  const ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
11291
12180
  const timer = ctl ? setTimeout(() => { try { ctl.abort(); } catch {} }, FETCH_TIMEOUT_MS) : null;
@@ -11371,6 +12260,11 @@ async function loadDailyMetrics() {
11371
12260
  .map(k => ({ key: k, timedOut: !!fetchResults[k].timedOut }));
11372
12261
  renderDailyMetrics();
11373
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 {}
11374
12268
  } catch (e) {
11375
12269
  if (chartEl) chartEl.innerHTML = '<div class="views-chart-empty">Unable to load daily metrics (' + escapeHtml(String(e.message || e)) + ').</div>';
11376
12270
  }
@@ -11668,7 +12562,7 @@ let _styleStatsTableState = { sortField: 'score', sortDir: 'desc', filters: {} }
11668
12562
  // OS-level title-attribute delay.
11669
12563
  const STYLE_STATS_HELP = {
11670
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.',
11671
- 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.',
11672
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.)',
11673
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.',
11674
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.',
@@ -11715,17 +12609,31 @@ function formatStyleCell(name, metaMap) {
11715
12609
  const m = (metaMap && metaMap[name]) || null;
11716
12610
  if (!m || name === '(none)') return safeName;
11717
12611
  const lines = [];
11718
- if (m.description) lines.push(m.description);
11719
- if (m.note) lines.push('Note: ' + m.note);
11720
- 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
+ }
11721
12626
  const status = m.status || 'active';
11722
12627
  const provenance = [];
11723
12628
  if (status && status !== 'active') provenance.push('status=' + status);
11724
12629
  if (m.invented_at) provenance.push('invented ' + String(m.invented_at).slice(0, 10));
11725
12630
  if (m.first_post_platform) provenance.push('first on ' + m.first_post_platform);
11726
12631
  if (m.promoted_at) provenance.push('promoted ' + String(m.promoted_at).slice(0, 10));
11727
- if (provenance.length) lines.push(provenance.join(' · '));
11728
- 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;
11729
12637
  const tip = lines.join('\\n');
11730
12638
  return '<span data-tooltip="' + escapeHtml(tip) + '" style="cursor: help; border-bottom: 1px dotted var(--text-muted);">' + safeName + '</span>';
11731
12639
  }
@@ -11788,15 +12696,17 @@ function renderStyleStats(payload, meta) {
11788
12696
  const per = total / denom;
11789
12697
  return fmt(total) + ' <span style="color:var(--text-muted);">(' + perPostStr(per) + ')</span>';
11790
12698
  };
11791
- // Per-post score matches top_performers.SCORE_SQL (comments*3 + upvotes, Reddit
11792
- // self-upvote discounted at SQL layer). Views deliberately excluded so this is
11793
- // the same signal Claude uses for imitation; comparing by per-post keeps low-
11794
- // 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.
11795
12704
  const normalized = rows.map(r => {
11796
12705
  const posts = Number(r.posts) || 0;
11797
12706
  const comments = Number(r.comments) || 0;
11798
12707
  const upvotesDiscounted = Number(r.upvotes_discounted) || 0;
11799
- 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;
11800
12710
  return {
11801
12711
  style: r.style || '(none)',
11802
12712
  posts,
@@ -11804,7 +12714,7 @@ function renderStyleStats(payload, meta) {
11804
12714
  upvotes: Number(r.upvotes) || 0,
11805
12715
  comments,
11806
12716
  views: Number(r.views) || 0,
11807
- post_clicks: Number(r.post_clicks) || 0,
12717
+ post_clicks: postClicks,
11808
12718
  recommendations: Number(r.recommendations) || 0,
11809
12719
  score,
11810
12720
  };
@@ -11886,13 +12796,15 @@ async function loadStyleStats() {
11886
12796
  }
11887
12797
 
11888
12798
  // Score-cohort distribution. Buckets posts in the trailing window into
11889
- // 4 cohorts (dead/low/mid/high) by composite score (comments*3 + upvotes,
11890
- // 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).
11891
12803
  const COHORT_DEFS = [
11892
- { key: 'dead', label: 'Dead', scoreLabel: '0', blurb: 'No discussion, no upvotes beyond the OP self-upvote (Reddit/Moltbook). Skip imitating.' },
11893
- { 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.' },
11894
- { key: 'mid', label: 'Mid', scoreLabel: '5\u201314', blurb: 'Real but modest reaction. Roughly 1\u20134 comments, or 5\u201314 upvotes, or a mix.' },
11895
- { 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.' },
11896
12808
  ];
11897
12809
  const COHORT_COLORS = { dead: '#9ca3af', low: '#60a5fa', mid: '#22c55e', high: '#a855f7' };
11898
12810
 
@@ -11932,7 +12844,7 @@ function renderCohortStats(payload) {
11932
12844
  const headers = [
11933
12845
  { label: 'Cohort', tip: 'Bucket name. Tooltip on each row explains what that range means.' },
11934
12846
  { label: 'Posts', tip: 'Number of posts in the bucket (and share of total in the current window/platform/project filter).' },
11935
- { 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.' },
11936
12848
  { label: 'Upvotes', tip: 'Upvote/like/reaction count range (min\u2013max) and average within this cohort. Raw, before the Reddit/Moltbook self-upvote discount.' },
11937
12849
  { label: 'Comments', tip: 'Reply/comment count range (min\u2013max) and average within this cohort.' },
11938
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.' },
@@ -12479,7 +13391,7 @@ function renderSearchQueriesStats(payload) {
12479
13391
  formatter: (v, row) => {
12480
13392
  const n = Number(v) || 0;
12481
13393
  if (row.serp_quality_avg != null) {
12482
- 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**';
12483
13395
  return '<span data-tooltip="' + escapeHtml(tip) + '">' + fmt(n) + '</span>';
12484
13396
  }
12485
13397
  return fmt(n);
@@ -12487,7 +13399,7 @@ function renderSearchQueriesStats(payload) {
12487
13399
  { key: 'dud_rate', label: 'Dud %', type: 'numeric', align: 'right', widthPct: 5,
12488
13400
  formatter: (v, row) => {
12489
13401
  const n = Number(v) || 0;
12490
- 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';
12491
13403
  const color = n >= 0.5 ? 'var(--danger,#dc2626)' : (n >= 0.25 ? 'var(--warn,#d97706)' : 'var(--text-secondary)');
12492
13404
  return '<span data-tooltip="' + escapeHtml(tip) + '" style="color:' + color + ';font-variant-numeric:tabular-nums;">' + pct(n) + '</span>';
12493
13405
  } },
@@ -12500,13 +13412,15 @@ function renderSearchQueriesStats(payload) {
12500
13412
  { key: 'avg_engagement', label: 'Avg Eng', type: 'numeric', align: 'right', widthPct: 4,
12501
13413
  formatter: v => {
12502
13414
  if (v == null) return '<span style="color:var(--text-faint);">\u2014</span>';
12503
- 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)';
12504
13417
  return '<span data-tooltip="' + escapeHtml(tip) + '" style="font-variant-numeric:tabular-nums;">' + fmt1(v) + '</span>';
12505
13418
  } },
12506
13419
  { key: 'avg_views', label: 'Avg Views', type: 'numeric', align: 'right', widthPct: 4,
12507
13420
  formatter: v => {
12508
13421
  if (v == null) return '<span data-tooltip="LinkedIn does not expose comment views" style="color:var(--text-faint);">\u2014</span>';
12509
- 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)';
12510
13424
  return '<span data-tooltip="' + escapeHtml(tip) + '" style="font-variant-numeric:tabular-nums;">' + fmt(Math.round(v)) + '</span>';
12511
13425
  } },
12512
13426
  { key: 'last_run', label: 'Last Run', type: 'numeric', align: 'right', widthPct: 4,
@@ -12566,11 +13480,11 @@ function renderCostStats(payload) {
12566
13480
  if (totalEl) {
12567
13481
  totalEl.textContent = '$' + totalCost.toFixed(2) + ' · ' + totalCount.toLocaleString() + ' activit' + (totalCount === 1 ? 'y' : 'ies');
12568
13482
  const tipLines = [
12569
- 'Cost (SDK orchestrator): $' + totalOrch.toFixed(4),
13483
+ '**Cost (SDK orchestrator):** $' + totalOrch.toFixed(4),
12570
13484
  '',
12571
- 'Diagnostic-only (local pricing estimate, not actual billing):',
12572
- ' Transcript estimate: $' + totalEst.toFixed(4),
12573
- ' Subagent (est): $' + totalSub.toFixed(4),
13485
+ '**Diagnostic-only** (local pricing estimate, not actual billing)',
13486
+ 'Transcript estimate: $' + totalEst.toFixed(4),
13487
+ 'Subagent (est): $' + totalSub.toFixed(4),
12574
13488
  '',
12575
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.',
12576
13490
  ];
@@ -12587,11 +13501,11 @@ function renderCostStats(payload) {
12587
13501
  function fmtCount(v) { return (Number(v) || 0).toLocaleString(); }
12588
13502
  function moneyCell(displayed, orch, est, sub) {
12589
13503
  const tip = [
12590
- 'Cost (SDK orchestrator): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
13504
+ '**Cost (SDK orchestrator):** ' + (orch != null ? fmtMoney(orch) : 'n/a'),
12591
13505
  '',
12592
- 'Diagnostic-only (local pricing estimate):',
12593
- ' Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
12594
- ' Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
13506
+ '**Diagnostic-only** (local pricing estimate)',
13507
+ 'Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
13508
+ 'Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
12595
13509
  ].join('\\n');
12596
13510
  return '<span data-tooltip="' + escapeHtml(tip) +
12597
13511
  '" style="cursor:help;border-bottom:1px dotted var(--text-muted);">' +
@@ -13129,7 +14043,15 @@ function renderTopPosts(payload) {
13129
14043
  let clickLine = '';
13130
14044
  if (hasLink) {
13131
14045
  if (havePerClick) {
13132
- 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';
13133
14055
  clickLine = '<span class="top-stats-bit" data-tooltip="' + escapeHtml(tip) + '">'
13134
14056
  + '<span class="top-stats-k">clicks</span>'
13135
14057
  + real
@@ -13137,7 +14059,14 @@ function renderTopPosts(payload) {
13137
14059
  + ' <span style="color:var(--text-muted);">/ ' + bots + '</span>'
13138
14060
  + '</span>';
13139
14061
  } else if (backfill > 0) {
13140
- 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';
13141
14070
  clickLine = '<span class="top-stats-bit" data-tooltip="' + escapeHtml(tip) + '">'
13142
14071
  + '<span class="top-stats-k">clicks</span>'
13143
14072
  + backfill
@@ -13145,7 +14074,14 @@ function renderTopPosts(payload) {
13145
14074
  + ' <span style="color:var(--text-muted);">(estimated)</span>'
13146
14075
  + '</span>';
13147
14076
  } else if (legacy > 0) {
13148
- 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)';
13149
14085
  clickLine = '<span class="top-stats-bit" data-tooltip="' + escapeHtml(tip) + '">'
13150
14086
  + '<span class="top-stats-k">clicks</span>'
13151
14087
  + legacy
@@ -14272,7 +15208,11 @@ function renderTopDms(payload) {
14272
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>';
14273
15209
  } else {
14274
15210
  const lastAt = r.short_link_last_click_at ? new Date(r.short_link_last_click_at).toLocaleString() : 'never';
14275
- 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');
14276
15216
  const color = clicks > 0 ? 'var(--accent)' : 'var(--text-muted)';
14277
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>';
14278
15218
  }
@@ -14282,7 +15222,14 @@ function renderTopDms(payload) {
14282
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>';
14283
15223
  } else {
14284
15224
  const lastAt = r.last_booking_at ? new Date(r.last_booking_at).toLocaleString() : '';
14285
- 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);
14286
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>';
14287
15234
  }
14288
15235
 
@@ -14295,13 +15242,19 @@ function renderTopDms(payload) {
14295
15242
  const wrapped = !!(r.booking_link_sent_at || linkCount > 0);
14296
15243
  const detected = !!r.outbound_url_detected;
14297
15244
  if (!wrapped && !detected) return '<span style="color:var(--text-faint);">No</span>';
14298
- const tipParts = [];
14299
- if (r.booking_link_sent_at) tipParts.push('booking link sent: ' + new Date(r.booking_link_sent_at).toLocaleString());
14300
- if (linkCount > 0) tipParts.push(linkCount + ' wrapped link' + (linkCount === 1 ? '' : 's'));
14301
- if (r.short_link_code) tipParts.push('latest: /r/' + String(r.short_link_code));
14302
- if (!wrapped && detected) tipParts.push('raw URL detected in outbound text (wrap pipeline bypassed - no dm_links row, click tracking missing)');
14303
- else if (detected && wrapped) tipParts.push('also: raw URL in outbound text');
14304
- 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));
14305
15258
  const tipAttr = tip ? ' data-tooltip="' + escapeHtml(tip) + '"' : '';
14306
15259
  const label = wrapped ? 'Yes' : 'Yes*';
14307
15260
  const color = wrapped ? 'var(--success)' : '#b45309';
@@ -15111,11 +16064,11 @@ function renderProjectStatus(data, opts) {
15111
16064
  // SDK-only mode: displayed value comes from orchestrator_cost_usd; the
15112
16065
  // estimate and subagent are diagnostic-only (local pricing table).
15113
16066
  const tip = [
15114
- 'Cost (SDK orchestrator): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
16067
+ '**Cost (SDK orchestrator):** ' + (orch != null ? fmtMoney(orch) : 'n/a'),
15115
16068
  '',
15116
- 'Diagnostic-only (local pricing estimate, not actual billing):',
15117
- ' Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
15118
- ' Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
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'),
15119
16072
  '',
15120
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.',
15121
16074
  ].join('\\n');
@@ -15133,11 +16086,11 @@ function renderProjectStatus(data, opts) {
15133
16086
  : base;
15134
16087
  if (costAvailable) {
15135
16088
  const tipLines = [
15136
- 'Cost (SDK orchestrator): ' + fmtMoney(grandCostOrch),
16089
+ '**Cost (SDK orchestrator):** ' + fmtMoney(grandCostOrch),
15137
16090
  '',
15138
- 'Diagnostic-only (local pricing estimate, not actual billing):',
15139
- ' Transcript estimate: ' + fmtMoney(grandCostEst),
15140
- ' Subagent (est): ' + fmtMoney(grandCostSub),
16091
+ '**Diagnostic-only** (local pricing estimate, not actual billing)',
16092
+ 'Transcript estimate: ' + fmtMoney(grandCostEst),
16093
+ 'Subagent (est): ' + fmtMoney(grandCostSub),
15141
16094
  '',
15142
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.',
15143
16096
  ];
@@ -15412,7 +16365,11 @@ async function loadFunnelStats(force) {
15412
16365
  if (_funnelStatsLoading) return;
15413
16366
  if (saAuthNotReady()) return;
15414
16367
  const days = currentStatsWindow().days;
15415
- 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;
15416
16373
  _funnelStatsLoading = true;
15417
16374
  const totalEl = document.getElementById('funnel-stats-total');
15418
16375
  const body = document.getElementById('funnel-stats-body');
@@ -15421,11 +16378,13 @@ async function loadFunnelStats(force) {
15421
16378
  body.innerHTML = '<div class="style-stats-empty">Loading\u2026 (first call can take 15\u201330s)</div>';
15422
16379
  }
15423
16380
  try {
15424
- 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('&'));
15425
16384
  const data = await res.json();
15426
16385
  if (data && !data.error) _lastFunnelPayload = data;
15427
16386
  renderFunnelStats(data);
15428
- _funnelStatsLoadedFor = days;
16387
+ _funnelStatsLoadedFor = loadKey;
15429
16388
  } catch (e) {
15430
16389
  if (body) body.innerHTML = '<div class="style-stats-empty">Failed to load.</div>';
15431
16390
  } finally {
@@ -15808,6 +16767,15 @@ _saInstallDeleteListener();
15808
16767
  });
15809
16768
  })();
15810
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
+
15811
16779
  // Lazy-load funnel stats the first time the user opens the section. The fetch
15812
16780
  // shells out to PostHog and two Postgres DBs, so we don't want to run it on
15813
16781
  // every page load.