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